Sessions, turns and the event log
The log is the state. Everything else is a view of it.
A session is a conversation with one version of one agent. Its state is an
append-only event log, keyed (session, seq), with a dense sequence — there is
never a gap, which is what makes an after cursor safe.
That log is not an audit trail bolted onto the side. It is the state: the prompt is rebuilt from it at every step, the stream a browser reads is it, the trace and the usage meter are folded from it. A turn that dies mid-flight resumes because the log says what was in flight.
A turn
sessions.create and sessions.send append a user.message and wake the session.
One turn is: build the prompt from the log, call the model, record what it said,
run the tools it asked for, repeat until it asks for no more tools or limits.maxTurns
is reached.
user.message
session.status running
model.request.started ← one per model call, with its requestId
agent.text_delta … ← ephemeral: never persisted, never a resume point
agent.message ← the durable one
model.request.completed ← tokens, cost, cache hit/miss
tool.call.started ← every call of a step is recorded before any runs
tool.call.completed
… another step …
session.usage ← what this turn cost
session.status idle ← always written, on every exit pathsession.status is running, idle or terminated, and an idle carries a
stopReason: completed, interrupted, max_turns, budget_reached, error,
requires_action, decision_expired, server_restart, orphaned.
The events worth knowing
| Event | When |
|---|---|
user.message | A message you sent. Carries the delivery mode, any attachments and any message metadata. |
agent.message | What the model said. Durable; this is the one to render and to store. |
agent.text_delta | Ephemeral typing preview. Never persisted — a client that reconnects sees the agent.message instead. |
agent.reasoning_delta, tool.call.input_delta | The same, for reasoning and for tool arguments being composed. |
tool.call.started / .completed | Every tool call, whoever runs it, with its input and its result. |
tool.call.pending / .decision | A call waiting on a person, and the answer. |
model.request.started / .completed / .failed / .retried | One per model call, per fallback rung. Tokens, cache counters and cost land on .completed. |
session.status | Every transition, with a stopReason on an idle. |
session.usage | The turn's meter, written immediately before the terminal status. |
session.error | Something degraded and the turn continued — an unreachable connection, a failed summariser. |
session.metadata, message.metadata | A change to session metadata, or an annotation of one message. |
subagent.spawned / .status / .message / .completed | The four things a child session reports to its parent. |
connection.authorization_required / .authorized | A tool needs an OAuth grant this identity does not have yet. |
sandbox.attached / .released / .idle / .snapshotted / .resumed | The lifecycle of the machine. |
session.artifact | Something the agent published — a preview URL, for instance. |
compaction, cache.prefix | The transcript was compacted; the cached prefix was pinned or re-pinned. |
hook.dead_letter | A hook delivery gave up. |
Reading it
// A page of the log.
const { events } = await client.sessions.events({ id, after: 0, limit: 200 })
// A tail, server-side. Reconnects are the caller's business; `after` makes them safe.
for await (const event of client.sessions.stream(id, { after: lastSeq })) { … }For a browser, do not proxy either of those: mint a stream token and let the page read the SSE route directly.
Interrupting
await client.sessions.interrupt({ id })This cuts the running turn immediately — it does not go through the mailbox. The
session writes session.status(idle, interrupted); a tool call that was in flight
stays open in the log and is rendered to the model as interrupted on the next turn.
To redirect rather than stop, send with mode: "steer" — see
delivery modes.
Durability
A crash loses no completed work. On restart — or within about thirty seconds, from another process — Plane folds the tail of the log and decides one thing per open item:
- An open model request is re-issued with the same
requestId, and the double charge is recorded asmodel.request.retried. - An open tool call the MCP server annotated
readOnlyHintoridempotentHintis re-invoked. Anything else is completed with an interrupted result and the model decides what to do. - A call waiting on a person is left alone. It was not in flight; it was parked.
session.resumed records the counts. There is no separate liveness table and nothing
to poll: a log whose newest status is running is by construction a turn whose runner
went away.
Listing
await client.sessions.list({
agentId,
status: "idle",
filter: { metadata: { customerId: "cus_42" } }, // containment; see Metadata
limit: 50
})
await client.sessions.children({ id }) // sub-agent sessions of this one