Skip to content

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) or session.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 ​

typescript
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):

json
{
  "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"
}
FieldNotes
seqPosition in the conversation's log; strictly increasing. Live-only agent.message.delta events have none
typeThe event type (below)
dataType-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_idThe background run the event belongs to, when there is one
created_atISO 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 ​

typedataWhen
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.compactionobjectThe 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.

typescript
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) ​

typescript
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>

QueryDefaultDescription
after0Only events with seq greater than this
typesallOnly these event types
limit100Page 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.

typescript
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 ​

OptionDefaultDescription
after0Resume after this seq
typesallOnly these event types
signal—Stops the stream; the iterator then throws the signal's abort reason
maxReconnects8Consecutive reconnect attempts that receive no data before the iterator gives up and throws. A connection that delivered anything resets the count. Infinity retries forever
reconnectDelayMs1000First reconnect delay; doubles per failed attempt
maxReconnectDelayMs15000Upper bound of the reconnect delay
idleTimeoutMs45000Reconnect 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. break out of the loop (for example on session.status_idle) or pass a signal.
  • 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-ID set 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.delta chunks are not replayed after a reconnect — the full text arrives as agent.message.
  • Backoff. Reconnect delays double from reconnectDelayMs up to maxReconnectDelayMs. A Retry-After from the server is honoured up to 30 s.
  • Retryable vs fatal. Network failures, idle stalls, a server event: error frame and HTTP 408, 425, 429 and 5xx are retried. Any other HTTP status (401, 403, 404, …) fails at once — it will not change on retry.
  • Abort. When signal aborts, the iterator throws the abort reason (a DOMException AbortError by default). Catch it if you abort on purpose.

Catch up after a reload ​

typescript
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 ​

bash
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"
text
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"}

: ping

Stored 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 ​

LimitValue
events.list page sizedefault 100, max 1000
Event data size16 KB, then _truncated: true
Stream lifetimeone hour per connection (the SDK reconnects)
Keep-alive: ping every 15 s
RetentionThe Console applies a retention policy to event logs — do not treat the log as permanent storage

Errors ​

StatuserrorCodeWhen
404session_not_foundNo 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.

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