Plane docs
Concepts

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 path

session.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

EventWhen
user.messageA message you sent. Carries the delivery mode, any attachments and any message metadata.
agent.messageWhat the model said. Durable; this is the one to render and to store.
agent.text_deltaEphemeral typing preview. Never persisted — a client that reconnects sees the agent.message instead.
agent.reasoning_delta, tool.call.input_deltaThe same, for reasoning and for tool arguments being composed.
tool.call.started / .completedEvery tool call, whoever runs it, with its input and its result.
tool.call.pending / .decisionA call waiting on a person, and the answer.
model.request.started / .completed / .failed / .retriedOne per model call, per fallback rung. Tokens, cache counters and cost land on .completed.
session.statusEvery transition, with a stopReason on an idle.
session.usageThe turn's meter, written immediately before the terminal status.
session.errorSomething degraded and the turn continued — an unreachable connection, a failed summariser.
session.metadata, message.metadataA change to session metadata, or an annotation of one message.
subagent.spawned / .status / .message / .completedThe four things a child session reports to its parent.
connection.authorization_required / .authorizedA tool needs an OAuth grant this identity does not have yet.
sandbox.attached / .released / .idle / .snapshotted / .resumedThe lifecycle of the machine.
session.artifactSomething the agent published — a preview URL, for instance.
compaction, cache.prefixThe transcript was compacted; the cached prefix was pinned or re-pinned.
hook.dead_letterA 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 as model.request.retried.
  • An open tool call the MCP server annotated readOnlyHint or idempotentHint is 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

On this page