# 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](/docs/reference/tools.md#call-a-tool-without-a-model-turn) 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](#track-which-follow-up-reached-the-agent) |
| Turn | `turn.queued`, `turn.started`, `turn.completed`, `turn.failed` | Queue position under a [`maxRunningTurns` cap](/docs/reference/agent-config.md#concurrency), 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](/docs/reference/artifacts.md) 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](/docs/reference/instructions.md#delivery).

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](/docs/reference/agent-config.md#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>.ndjson
```

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

## Related

- [HTTP API](/docs/reference/http-api.md)
- [Hooks](/docs/reference/hooks.md)
- [Tools](/docs/reference/tools.md)
