Appearance
Sessions, events, and streaming
A session keeps one conversation, its workspace, and an append-only record of every message and tool call. Continue it with a continuation token, inspect it with a session ID, and follow progress on the NDJSON stream. Chat sessions wait for follow-ups; task sessions run once and stop.
Session contents
Each session combines:
- A channel and authenticated caller
- A conversation the caller can continue
- A workspace for local turns
- An NDJSON event stream
- State that lets the conversation resume after a restart
Sessions belong to the principal that created them. Follow-up, stream, and list routes return 403 when another caller tries to access one.
Session identifiers
Sessions have two identifiers because conversation routing and inspection are different jobs.
| Identifier | Use it for |
|---|---|
continuationToken | Continue a conversation through its channel |
sessionId | Stream events, inspect state, or run a session-bound tool call |
Channels decide what a continuation token looks like. Slack uses its thread identity. A PR channel can use a key such as pr:owner/repo#1. The built-in HTTP API returns an opaque token and rotates it after each accepted follow-up. Reusing a stale HTTP token returns 409.
Use the continuation token to keep talking. Use the session ID to observe or manage the stored session.
Session modes
| Mode | Created by | What happens after a turn |
|---|---|---|
chat | HTTP sessions, channel send, Slack, or MCP ask | Waits in session.waiting and accepts follow-ups |
task | Markdown schedules and fire-and-forget dispatch | Ends in session.completed or session.failed |
Task sessions don't accept follow-ups. Trying one returns 409.
Follow-ups
A follow-up to an idle chat session starts another turn. Admission when the session is already busy depends on the channel:
| Path | Busy-session policy |
|---|---|
HTTP playground / POST /v1/session/:id / MCP ask | Preempt (default): interrupt the in-flight turn, wait for it to settle, then run the new message |
| Slack mentions / DMs / alert-watch | Coalesce: leave the active turn running and queue the follow-up. The running turn may receive it at a tool boundary; any asks that remain become one follow-up turn when it finishes |
Pass admission: "coalesce" on send() to opt into the Slack policy from other callers. Omit it (or pass "preempt") to keep interrupt semantics.
Track which follow-up reached the agent
Every send() returns a deliveryId. Pass your own as the deliveryId option, such as the webhook delivery ID you already hold, or omit it to get a fresh dlv_… ID. A coalesced follow-up reaches the agent in one of two ways, and the ID is on the event for that path:
| Path | Event | Field |
|---|---|---|
| Steered into the running turn | nudge.delivered | data.deliveryId |
| Fell back to the next turn | That turn's message.received | data.deliveryIds |
message.received lists every send its prompt carries, in arrival order. Follow-ups queued behind a busy turn drain into one turn, so the list can hold several IDs. A direct send that starts a turn gets a one-item list.
ts
// In a route or channel hook: name the steer after the webhook delivery.
await send(`Incident ${incident.id} was acknowledged.`, {
continuationToken: incident.id,
admission: "coalesce",
deliveryId: webhookId,
});
// In the channel's `events` map:
const steered = new Set<string>();
const prompted = new Set<string>();
const events: ChannelEventHandlers = {
"nudge.delivered": async (event) => {
if (event.data.deliveryId !== undefined) {
steered.add(event.data.deliveryId);
}
},
"message.received": async (event) => {
for (const id of event.data.deliveryIds ?? []) {
prompted.add(id);
}
},
};A local Slack session can also pick up follow-ups at a tool boundary from its own thread store. That nudge.delivered has no deliveryId, which tells it apart from a steered send.
POST /v1/session/:id/stop interrupts a turn without sending a new message. The stream records turn.failed with status: "cancelled"; the session stays a chat session and can take another follow-up. A whole-message Slack stop / @agent stop does the same for that thread and clears pending coalesced follow-ups.
See Tools for direct-call behavior while a session is busy.
Stream events
Each NDJSON line uses this envelope: { type, index, sessionId, turnId?, at, data }. The index increases within one session. The at field is an ISO-8601 timestamp.
| Phase | Events | What they tell you |
|---|---|---|
| Session | session.started, session.waiting, session.completed, session.failed | Session creation, readiness, and task completion |
| Input | message.received, nudge.delivered | A user message was accepted as a turn's prompt, or reached a running turn mid-turn. Both name the sends they carry |
| Turn | turn.queued, turn.started, turn.completed, turn.failed | Queue position under a maxRunningTurns cap, then turn status, final result, and token usage |
| Steps | step.started, step.completed | Model step boundaries and duration |
| Reasoning | reasoning.appended, reasoning.completed | Streamed reasoning blocks |
| Reply | message.appended, message.completed | Text deltas and finalized assistant messages |
| Tools | actions.requested, actions.completed, action.result, action.tool_retry | Tool names, validated arguments, outputs, and errors. action.tool_retry means a read is running again after a transient failure |
| Subagents | subagent.called, subagent.completed | Delegated work |
| Artifacts | artifact.tagged | A durable artifact was tagged for this session, by host code or tag_artifact |
Pair actions.requested with actions.completed for each model-step batch. action.result is the live per-call envelope. Read turn.completed.data.usage for input, output, and cache token counts.
Stream or replay events
One endpoint handles both live streaming and replay:
bash
curl -N 'http://127.0.0.1:3000/<slug>/v1/session/ses_…/stream?startIndex=0'Pass startIndex to continue after the last event you received, including after a server restart. Omit it or pass 0 to replay the full session before following new events. GET /v1/session/:id/events returns a one-time dump without staying connected.
Local session workspace
Agent SDK creates a workspace before the first local turn:
| Source path | Lands as |
|---|---|
skills/* | .cursor/skills/<name>/SKILL.md |
agent tools (execution: "agent") | scripts in the session workspace, with a catalog in AGENTS.md |
sandbox/workspace/** | copied in as seed files |
per-send workspaceFiles | written before the turn |
Instructions reach the model as its system prompt; they aren't written into the workspace. See Instructions.
Local turns use this workspace as their working directory. Parent directories can contribute AGENTS.md and .cursor settings. Set local.cwd when you need a clean parent directory. A channel can also provide a different working directory for one session, such as a PR worktree.
See Agent config: local cwd for the inheritance rules.
Session storage
Local session state lives under --state-root. A slugged mount stores it in a subdirectory named for the slug.
Deleting a session directory removes the session from the server: it disappears from listings and can no longer be streamed or continued.
Inspect a saved event stream
Use session get --file with a saved trace:
text
agent-sdk session get --file <state-root>/traces/<sessionId>.ndjsonThe command prints tool calls, the reply, and token usage in the same JSON shape as run. Open the session's Trace view in the playground for a visual timeline.