Skip to content

Agent Sessions ​

Four runnable examples for @cognipeer/console-sdk 3.1.0, plus one bonus. Each one stands alone; the Agent Sessions guide explains how they fit together.

ExampleShows
Create a session, upload a file, asksessions.create, files.upload, responses.create({ session_id }), reading the text the agent read
Approve a tool callrequires_action, actions.resolve, background continuation
Follow session eventsevents.stream, catching up, aborting
Verify a webhookwebhooks.create, verifyWebhook, dedupe, replay
Find sessions waiting for an answersessions.list({ status }), get, an approval inbox

Prerequisites: npm install @cognipeer/console-sdk, an API token in COGNIPEER_API_KEY, and a Console with the agent runtime release. For the first example the agent needs Configure → Files switched on with a bucket; for the second, Approvals & questions switched on.

Create a session, upload a file, ask ​

typescript
import { readFile } from 'node:fs/promises';
import { ConsoleClient, CognipeerAPIError, AgentActionErrorCodes, isAgentActionErrorCode } from '@cognipeer/console-sdk';

const client = new ConsoleClient({ apiKey: process.env.COGNIPEER_API_KEY! });

async function askAboutInvoice(path: string) {
  // 1. Does this agent take files, and which ones?
  const agent = await client.agents.get('support-bot');
  if (!agent.attachments?.enabled) {
    throw new Error('support-bot takes no files: switch Files on in the agent\'s Configure settings');
  }
  const { max_file_size_mb, allowed_mime_types } = agent.attachments;
  console.log(`up to ${max_file_size_mb} MB, types: ${allowed_mime_types.join(', ') || 'any'}`);

  // 2. Start an empty session. Nothing runs, nothing is spent.
  const session = await client.agents.sessions.create({
    agent: 'support-bot',
    title: 'Invoice question',
    metadata: { ticket: 'T-1042' },
  });

  try {
    // 3. Upload first...
    const file = await client.agents.sessions.files.upload(session.id, await readFile(path), {
      name: 'invoice.pdf',
      mimeType: 'application/pdf',
    });
    console.log(file.bucket_key, file.path, file.conversion_status);
    // 'session-docs'  'agent-sessions/support-bot/<sessionId>/invoice-….pdf'  'pending' | 'succeeded'

    // 4. ...then ask. The new upload rides along with this message.
    const reply = await client.agents.responses.create({
      model: 'support-bot',
      session_id: session.id,
      input: 'What is the total on this invoice, and when is it due?',
    });
    console.log(reply.output[0]?.content[0]?.text);

    // 5. Follow-up that names the file explicitly.
    const followUp = await client.agents.responses.create({
      model: 'support-bot',
      session_id: session.id,
      input: [{
        role: 'user',
        content: [
          { type: 'input_text', text: 'Who is it addressed to?' },
          { type: 'input_file', file_id: file.id },
        ],
      }],
    });
    console.log(followUp.output[0]?.content[0]?.text);

    // 6. What did the agent actually read? The converted text.
    const { data } = await client.agents.sessions.files.content(session.id, file.id, { variant: 'markdown' });
    console.log(new TextDecoder().decode(data).slice(0, 300));
  } catch (error) {
    if (isAgentActionErrorCode(error, AgentActionErrorCodes.AttachmentsDisabled)) {
      console.error('Files are off for this agent, or on with no bucket chosen.');
    } else if (isAgentActionErrorCode(error, AgentActionErrorCodes.StorageUnavailable)) {
      console.error('The agent\'s bucket cannot take a file now:', (error as CognipeerAPIError).message);
    } else if (error instanceof CognipeerAPIError && [409, 413, 415, 429].includes(error.statusCode ?? 0)) {
      console.error(`Upload refused (${error.errorCode}): ${error.message}`); // too_many_files, file_too_large, …
    } else {
      throw error;
    }
  } finally {
    // 7. Remove the session with its file, events and open actions.
    await client.agents.sessions.delete(session.id);
  }
}

await askAboutInvoice('./invoice.pdf');

What you should see: the file's bucket_key and path, the agent's answer about the invoice, and the first 300 characters of the markdown the agent read. If conversion_status is pending, the text is not ready yet; ask again after a moment, or fall back to the original (variant: 'original').

Notes

  • A file nobody has sent yet goes with the session's next message automatically (unless the agent's Send new uploads with the next message setting is off). Name it with input_file when you want control.
  • An image reaches the model only when agent.attachments.accepts_images is true; otherwise the turn records a note that the image was not sent.
  • Browser or Next.js route: pass the File straight in — files.upload(session.id, file) takes its name and type from the File.

Approve a tool call ​

An agent with Ask before tools run pauses before it executes a tool. The call comes back requires_action; you answer; the turn continues.

typescript
import {
  ConsoleClient,
  AgentActionErrorCodes,
  isAgentActionErrorCode,
  type AgentActionsPendingResponse,
  type AgentResponse,
  type PendingAction,
} from '@cognipeer/console-sdk';

const client = new ConsoleClient({ apiKey: process.env.COGNIPEER_API_KEY! });

// Your policy. In a real app this is a person, a queue or a rules engine.
function decide(action: PendingAction) {
  if (action.kind === 'user_question') {
    const q = action.questions![0];
    return { answers: { [q.question]: q.options?.[0]?.value ?? q.options?.[0]?.label ?? 'staging' } };
  }
  const safe = ['sandbox_exec', 'read_file'].includes(action.tool_name ?? '');
  return safe
    ? ({ decision: 'allow', comment: 'Allow-listed tool', remember_for_session: false } as const)
    : ({ decision: 'deny', comment: `${action.tool_name} is not allowed here` } as const);
}

const first = await client.agents.responses.create({
  model: 'ops-agent',
  input: 'Clean up the staging database',
});

let outcome: AgentResponse | AgentActionsPendingResponse = first;
const conversationId = first.id.replace(/^resp_/, ''); // the session id

while (outcome.status === 'requires_action') {
  for (const action of outcome.required_action!.actions) {
    console.log(`Agent wants: ${action.kind === 'tool_approval' ? `${action.tool_name}(${JSON.stringify(action.args)})` : 'to ask a question'}`);

    try {
      outcome = await client.agents.sessions.actions.resolve(conversationId, action.id, decide(action));
      // While other actions of this pause are still open, `outcome` is a bare
      // `requires_action` body (empty output, no usage). The turn continues when the last is answered.
    } catch (error) {
      if (isAgentActionErrorCode(error, AgentActionErrorCodes.NotPending)) {
        console.log('Somebody else answered first');
      } else if (isAgentActionErrorCode(error, AgentActionErrorCodes.Expired)) {
        console.log('Too late: the request timed out');
        throw error;
      } else if (isAgentActionErrorCode(error, AgentActionErrorCodes.Forbidden)) {
        console.log('This token owner may not approve (needs agent.approve)');
        throw error;
      } else {
        throw error;
      }
    }
  }
}

if (outcome.status === 'completed') console.log(outcome.output[0]?.content[0]?.text);
else console.log(outcome.status, outcome.stop_reason); // 'incomplete', 'limit', …

What you should see: one log line per request ("Agent wants: sandbox_exec({"cmd":"…"})"), then the agent's final answer once every action is answered.

With a background run ​

A long continuation should not hold an HTTP request open. Start the turn in the background, wait until it needs you, answer in the background, and wait again:

typescript
const run = await client.agents.responses.create({
  model: 'ops-agent',
  input: 'Roll out the release to staging',
  background: true,
});

let current = await client.agents.runs.wait(run.id, { timeoutMs: 10 * 60_000 });

while (current.status === 'requires_action') {
  const conversationId = current.conversation_id!;
  for (const action of current.result!.required_action!.actions) {
    // Queue the continuation instead of waiting for it.
    const queued = await client.agents.sessions.actions.resolve(conversationId, action.id, decide(action), {
      background: true,
    });
    // While other actions of this pause are still open the Console answers with the bare
    // `requires_action` body, not a run: only the answer that completes the pause queues one.
    if (queued.object === 'agent.run') {
      current = await client.agents.runs.wait(queued.id, { timeoutMs: 10 * 60_000 });
    }
  }
}

console.log(current.status, current.result?.output[0]?.content[0]?.text);

runs.wait() returns when the run is requires_action (it would never finish on its own), so always check run.status.

Notes

  • Never auto-approve everything: an integration that answers every approval with allow defeats the control.
  • The token's owner needs the Console's agent.approve permission (the person who started the session always may).
  • approved_args lets the approver change what runs: { decision: 'allow', approved_args: { env: 'staging' } }.

Follow session events ​

Watch a turn as it happens: tool calls, the answer as it is typed, and when the session needs you or goes idle.

typescript
import { ConsoleClient, isSessionEvent, type SessionMessageDeltaEvent } from '@cognipeer/console-sdk';

const client = new ConsoleClient({ apiKey: process.env.COGNIPEER_API_KEY! });

async function watch(conversationId: string, signal: AbortSignal) {
  // Start from "now": skip history this process already has.
  const session = await client.agents.sessions.get(conversationId);

  try {
    for await (const event of client.agents.sessions.events.stream(conversationId, {
      after: session.last_event_seq,     // omit to replay the whole log first
      signal,
      types: [
        'agent.message.delta', 'agent.message',
        'agent.tool_use', 'agent.tool_result',
        'action.required', 'session.status_idle', 'session.error',
      ],
    })) {
      if (event.type === 'agent.message.delta') {
        process.stdout.write((event as SessionMessageDeltaEvent).data.text); // live only, not stored
      } else if (isSessionEvent(event, 'agent.tool_use')) {
        console.log(`\n→ ${event.data.name}(${JSON.stringify(event.data.args)})`);
      } else if (isSessionEvent(event, 'agent.tool_result')) {
        console.log(event.data.ok ? `✓ ${event.data.name} (${event.data.duration_ms ?? '?'} ms)` : `✗ ${event.data.name}: ${event.data.error}`);
      } else if (isSessionEvent(event, 'action.required')) {
        console.log(`\n⏸ needs an answer: ${event.data.kind} ${event.data.action_id}`);
      } else if (isSessionEvent(event, 'session.error')) {
        console.error(`\n! ${event.data.message}`);
      } else if (isSessionEvent(event, 'session.status_idle')) {
        console.log(`\n— idle (${event.data.stop_reason})`);
        break; // the stream never ends on idle by itself
      }
    }
  } catch (error) {
    if ((error as Error).name === 'AbortError') return; // we aborted on purpose
    throw error;
  }
}

const controller = new AbortController();
process.on('SIGINT', () => controller.abort());
await watch(process.argv[2]!, controller.signal);

What you should see, while another process sends a message to the session:

text
→ sandbox_exec({"cmd":"ls"})
✓ sandbox_exec (412 ms)
The directory holds three files…
— idle (end_turn)

Notes

  • The stream reconnects by itself (network drop, idle stall, the Console's one-hour cap) with Last-Event-ID, never repeating or skipping a stored event. Live agent.message.delta chunks are not replayed after a reconnect; the full text arrives as agent.message.
  • Render either deltas or agent.message for a given answer, not both.
  • Rebuild a transcript after a reload with events.list() (paged) instead of streaming from zero:
typescript
const events = [];
let after = 0;
for (;;) {
  const page = await client.agents.sessions.events.list(conversationId, { after, limit: 500 });
  events.push(...page.events);
  if (!page.has_more) break;
  after = page.last_seq;
}

Verify a webhook ​

Be told when a turn ends or an approval is waiting, without polling or holding a stream open.

Subscribe (needs webhooks.admin; run once, for example from a setup script):

typescript
import { ConsoleClient } from '@cognipeer/console-sdk';

const client = new ConsoleClient({ apiKey: process.env.COGNIPEER_API_KEY! });

const hook = await client.webhooks.create({
  url: 'https://example.com/hooks/cognipeer',
  events: ['action.required', 'session.idled', 'budget.reached', 'agent_run.*'],
  description: 'Support bot notifications',
});

console.log(hook.id);
console.log(hook.secret); // 'whsec_…' — shown ONCE: store it in your secret manager now

const delivery = await client.webhooks.test(hook.id);
console.log(delivery.status, delivery.lastStatusCode); // 'delivered' 204 once the receiver below runs

Receive (Express; the raw body is required for the signature):

typescript
import express from 'express';
import { verifyWebhook, type WebhookEvent } from '@cognipeer/console-sdk/webhooks';
import type { WebhookActionRequiredEvent, WebhookSessionIdledEvent } from '@cognipeer/console-sdk';

const app = express();
const seen = new Set<string>(); // use a durable store (Redis, a DB unique key) in production

app.post('/hooks/cognipeer', express.raw({ type: 'application/json' }), async (req, res) => {
  // 1. Verify on the RAW body, before anything else.
  const ok = verifyWebhook({
    rawBody: req.body,                                         // Buffer — not JSON.stringify(parsed)
    signatureHeader: req.get('X-Cognipeer-Signature'),
    secret: process.env.COGNIPEER_WEBHOOK_SECRET!,
    toleranceSeconds: 300,                                     // replay window (default)
  });
  if (!ok) return res.sendStatus(400);

  // 2. Deliveries are at-least-once: dedupe on webhook-id.
  const id = req.get('webhook-id') ?? '';
  if (seen.has(id)) return res.sendStatus(204);
  seen.add(id);

  // 3. Answer 2xx fast; do the work asynchronously.
  res.sendStatus(204);

  const event: WebhookEvent = JSON.parse(req.body.toString('utf8'));
  switch (event.type) {
    case 'action.required': {
      // data: { conversationId, agentKey, actionId, kind } — ids only; fetch the rest.
      const { conversationId } = (event as WebhookActionRequiredEvent).data;
      await notifyApprover(conversationId);
      break;
    }
    case 'session.idled': {
      const { conversationId, stopReason } = (event as WebhookSessionIdledEvent).data;
      await markTurnFinished(conversationId, stopReason);
      break;
    }
    default:
      break; // unknown types: acknowledge and ignore
  }
});

app.listen(3000);

What you should see: test() reports delivered with the status code your server returned. A request with a wrong secret or a re-serialised body is rejected with 400, and the Console keeps retrying that delivery.

Fix and replay a failed delivery ​

typescript
const { deliveries: failed } = await client.webhooks.deliveries(hook.id, { status: 'failed' });
for (const d of failed) {
  console.log(d.eventType, d.attempts, d.lastStatusCode, d.lastError);
}
await client.webhooks.redeliver(failed[0].id); // same event id, current URL and secret

If the endpoint shows disabled (it answered a redirect, resolved to a non-public address, or failed for 72 hours), fix the receiver, then await client.webhooks.update(hook.id, { status: 'active' }) and test() again.

Rotate the secret ​

typescript
const rotated = await client.webhooks.rotateSecret(hook.id); // the old secret stops working now
await secrets.put('COGNIPEER_WEBHOOK_SECRET', rotated.secret);

Find sessions waiting for an answer ​

An approval inbox: list every session that is requires_action and answer its pending actions.

typescript
for await (const s of client.agents.sessions.list({ status: 'requires_action' })) {
  const session = await client.agents.sessions.get(s.id); // the list row has no pending_actions
  for (const action of session.pending_actions) {
    console.log(s.id, s.agent, action.kind, action.kind === 'tool_approval' ? action.tool_name : '');
  }
}

Run it on a schedule if you cannot receive webhooks; with webhooks, react to action.required instead.

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub