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.
| Example | Shows |
|---|---|
| Create a session, upload a file, ask | sessions.create, files.upload, responses.create({ session_id }), reading the text the agent read |
| Approve a tool call | requires_action, actions.resolve, background continuation |
| Follow session events | events.stream, catching up, aborting |
| Verify a webhook | webhooks.create, verifyWebhook, dedupe, replay |
| Find sessions waiting for an answer | sessions.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
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_filewhen you want control. - An image reaches the model only when
agent.attachments.accepts_imagesistrue; otherwise the turn records a note that the image was not sent. - Browser or Next.js route: pass the
Filestraight in —files.upload(session.id, file)takes its name and type from theFile.
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.
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:
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
allowdefeats the control. - The token's owner needs the Console's
agent.approvepermission (the person who started the session always may). approved_argslets 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.
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:
→ 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. Liveagent.message.deltachunks are not replayed after a reconnect; the full text arrives asagent.message. - Render either deltas or
agent.messagefor a given answer, not both. - Rebuild a transcript after a reload with
events.list()(paged) instead of streaming from zero:
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):
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 runsReceive (Express; the raw body is required for the signature):
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
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 secretIf 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
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.
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.

