Session Events
Every session has an append-only event log: what the user said, what the agent did, which tools ran, what needed an answer, how each turn ended. client.agents.sessions.events reads it in pages (list) or follows it live (stream) — an async iterator that reconnects by itself and never repeats or skips a stored event.
New in 3.1.0
events.list() and events.stream(), the SessionEvent types and the isSessionEvent() guard. They need a Console with the agent runtime phase 1 release.
When to use
- A UI shows progress of a long turn: tool calls as they happen, the answer as it is written.
- A reload or a second device needs to catch up on a conversation it was not watching.
- You want an audit trail of what the agent did and who approved what.
- A backend wants to react to
action.required(notify an approver) orsession.status_idle(the turn is over).
If you only need to be told that something happened, a webhook is simpler than holding a stream open.
Quick start
import { isSessionEvent, type SessionMessageDeltaEvent } from '@cognipeer/console-sdk';
const session = await client.agents.sessions.get(conversationId);
const controller = new AbortController();
for await (const event of client.agents.sessions.events.stream(conversationId, {
after: session.last_event_seq, // omit to replay the whole log first
signal: controller.signal,
})) {
if (event.type === 'agent.message.delta') process.stdout.write((event as SessionMessageDeltaEvent).data.text); // live only
if (isSessionEvent(event, 'action.required')) notifyApprover(event.data.action_id);
if (isSessionEvent(event, 'session.status_idle')) break; // the stream itself never ends on idle
}The event envelope
A stored event looks like this on the wire (GET …/events):
{
"seq": 4,
"type": "agent.tool_use",
"data": { "tool_call_id": "call_1", "name": "sandbox_exec", "args": { "cmd": "ls" } },
"run_id": "run_66f2a1c8",
"created_at": "2026-10-12T09:16:03.120Z"
}| Field | Notes |
|---|---|
seq | Position in the conversation's log; strictly increasing. Live-only agent.message.delta events have none |
type | The event type (below) |
data | Type-specific. Keys are snake_case (tool_call_id, action_id, stop_reason, …); tool args and question answers are passed through exactly as the agent or user wrote them |
run_id | The background run the event belongs to, when there is one |
created_at | ISO timestamp |
Event data larger than 16 KB is cut and marked _truncated: true; fetch the rest by id from the resource it names (a tool result, a file) instead.
Event types
type | data | When |
|---|---|---|
user.message | { content } | The user's message was recorded |
session.status_running | { trigger: 'message' | 'resume' | 'timeout' } | A turn started |
agent.tool_use | { tool_call_id, name, args?, subagent? } | The agent called a tool |
agent.tool_result | { tool_call_id, name, ok, duration_ms?, error?, subagent? } | The tool returned (ok: false carries error) |
agent.compaction | object | The runtime compacted the conversation context |
action.required | { action_id, kind, tool_name?, args?, questions?, allow_free_text?, expires_at? } | The turn paused for an approval or a question — see Approvals & questions |
action.resolved | { action_id, kind, decision?, cancelled?, expired?, resolved_by, channel } | An action was answered, cancelled or timed out |
agent.message | { content, message_index?, stop_reason?, usage? } | The assistant's full message. stop_reason is present when the turn did not end with a plain answer (limit, requires_action, …) |
session.status_idle | { stop_reason, pending_action_ids?, detail? } | The turn ended and the session is idle (or waiting) |
session.error | { message, code? } | The turn failed |
session.work_done | {} | The agent finished its work |
session.usage | { turn_cost_usd, spent_usd, budget? } | Per-turn spend; written for a session that has a budget |
budget.reached | { spent_usd, max_cost_usd } | The budget was reached (the turn stopped; session.status_idle follows) |
agent.message.delta | { text } | Live only — a chunk of the answer in progress; never stored, never replayed |
SessionEvent is a union of these plus an UnknownSessionEvent for types a newer Console may add: ignore unknown types, do not throw on them. TypeScript cannot narrow the known union out of a type === check against that union — use the isSessionEvent(event, 'type') guard for every stored type. The live-only agent.message.delta is a SessionMessageDeltaEvent (what stream() yields besides SessionEvent); check event.type === 'agent.message.delta' and cast, as in the quick start.
import { isSessionEvent, type SessionStreamEvent } from '@cognipeer/console-sdk';
function render(event: SessionStreamEvent) {
if (isSessionEvent(event, 'agent.tool_use')) return `→ ${event.data.name}(${JSON.stringify(event.data.args)})`;
if (isSessionEvent(event, 'agent.tool_result')) return event.data.ok ? '✓' : `✗ ${event.data.error}`;
if (isSessionEvent(event, 'agent.message')) return event.data.content;
return null; // unknown or uninteresting
}List events (paging)
let after = 0;
for (;;) {
const page = await client.agents.sessions.events.list(conversationId, {
after, // only events with seq greater than this
types: ['agent.tool_use', 'agent.tool_result'], // optional filter
limit: 200,
});
for (const event of page.events) handle(event);
if (!page.has_more) break;
after = page.last_seq;
}GET /api/client/v1/agents/sessions/{id}/events?after=<seq>&types=a,b&limit=<n>
| Query | Default | Description |
|---|---|---|
after | 0 | Only events with seq greater than this |
types | all | Only these event types |
limit | 100 | Page size, max 1000 |
Answer: { object: 'list', events, has_more, last_seq } — oldest first. has_more says another page exists after last_seq; last_seq is the seq of the last event returned (or after when the page is empty).
Follow the log
events.stream() replays what is stored after after, then delivers new events as they happen — including live agent.message.delta text chunks.
const stream = client.agents.sessions.events.stream(conversationId, {
after: 0, // resume after this seq (default 0 = replay the log, then follow)
types: ['agent.message.delta', 'agent.message', 'session.status_idle'],
signal: controller.signal,
});
for await (const event of stream) { /* … */ }GET /api/client/v1/agents/sessions/{id}/events/stream (Server-Sent Events).
Options
| Option | Default | Description |
|---|---|---|
after | 0 | Resume after this seq |
types | all | Only these event types |
signal | — | Stops the stream; the iterator then throws the signal's abort reason |
maxReconnects | 8 | Consecutive reconnect attempts that receive no data before the iterator gives up and throws. A connection that delivered anything resets the count. Infinity retries forever |
reconnectDelayMs | 1000 | First reconnect delay; doubles per failed attempt |
maxReconnectDelayMs | 15000 | Upper bound of the reconnect delay |
idleTimeoutMs | 45000 | Reconnect when no bytes (events or the server's 15 s : ping) arrive for this long. 0 disables |
Behaviour
- It never ends on idle. When the session goes idle the stream stays open.
breakout of the loop (for example onsession.status_idle) or pass asignal. - It reconnects by itself after a network drop, an idle stall or the server's end of stream (the Console closes a stream after one hour), sending
Last-Event-IDset to the last seq seen. - No stored event is lost or repeated. A frame at or below the last seq already delivered is dropped. Live
agent.message.deltachunks are not replayed after a reconnect — the full text arrives asagent.message. - Backoff. Reconnect delays double from
reconnectDelayMsup tomaxReconnectDelayMs. ARetry-Afterfrom the server is honoured up to 30 s. - Retryable vs fatal. Network failures, idle stalls, a server
event: errorframe and HTTP408,425,429and5xxare retried. Any other HTTP status (401,403,404, …) fails at once — it will not change on retry. - Abort. When
signalaborts, the iterator throws the abort reason (aDOMExceptionAbortErrorby default). Catch it if you abort on purpose.
Catch up after a reload
const state = await client.agents.sessions.get(conversationId);
// Render the transcript from your own store, then follow only what is new:
for await (const event of client.agents.sessions.events.stream(conversationId, { after: state.last_event_seq, signal })) {
// …
}Or replay from the beginning with after: 0 (the default) and rebuild the view from the log.
Raw SSE
curl -N https://console.cognipeer.com/api/client/v1/agents/sessions/$ID/events/stream?after=12 \
-H "Authorization: Bearer $COGNIPEER_API_KEY" -H "Accept: text/event-stream"id: 13
event: agent.tool_use
data: {"seq":13,"type":"agent.tool_use","data":{"tool_call_id":"call_1","name":"sandbox_exec","args":{"cmd":"ls"}},"created_at":"2026-10-12T09:16:03.120Z"}
event: agent.message.delta
data: {"type":"agent.message.delta","data":{"text":"The directory "},"created_at":"2026-10-12T09:16:04.002Z"}
: pingStored events carry id: <seq>; live-only events have no id. A comment line : ping is sent every 15 seconds. Replay is read from the store before live events are delivered, with no gap between them. Last-Event-ID or ?after= resumes.
Limits
| Limit | Value |
|---|---|
events.list page size | default 100, max 1000 |
Event data size | 16 KB, then _truncated: true |
| Stream lifetime | one hour per connection (the SDK reconnects) |
| Keep-alive | : ping every 15 s |
| Retention | The Console applies a retention policy to event logs — do not treat the log as permanent storage |
Errors
| Status | errorCode | When |
|---|---|---|
| 404 | session_not_found | No such session in the token's project |
| 401 / 403 | — | The token is invalid or lacks access; stream() throws at once |
The stream also throws a CognipeerError named …stream ended / …failed after maxReconnects silent attempts: The session event stream ended, The session event stream failed, or No data on the session event stream for <n>ms.
Troubleshooting
The loop never ends. By design. break on session.status_idle (or session.work_done), or abort with a signal.
I get the same text twice. agent.message.delta is the live preview and agent.message is the final text. Render one or the other for a given answer; do not append both.
Deltas are missing after a reconnect. Live chunks are not stored. Show the agent.message that follows.
It throws after a while with The session event stream ended. More than maxReconnects consecutive connections ended without delivering any data. Check the network and that no proxy buffers the response (SSE must not be buffered), raise maxReconnects, or use Infinity for a worker that must never give up.
A proxy closes the connection periodically. The default idleTimeoutMs (45 s) is longer than the server's 15 s ping, so a healthy connection is never considered idle; the SDK reconnects with Last-Event-ID and loses nothing. Lower reconnectDelayMs if the gap matters.
Unknown event types appear. A newer Console added them. Ignore them.
Related
- Agent sessions · Approvals & questions · Webhooks
- Example: follow session events
- Streaming — chat completion streaming

