Runs, Cancel & Reattach
A turn - a message, or the answer to an approval - runs as a run, independent of the HTTP request that started it. Closing the tab, losing the connection or a proxy timeout does not stop it: the agent finishes, the answer and any pending approval are stored, and a client that comes back picks up where it left off. To stop a turn, say so: POST /conversations/:id/cancel.
What a run gives you:
| Detached | A streamed request is a subscriber of its run. Disconnecting unsubscribes; the run goes on. |
| Cancellable | POST /conversations/:id/cancel aborts the run: agent-sdk agents see it as their cancellationToken, custom handlers and host-backed agents as signal (host agents also get a cancel hook). |
| One at a time | A conversation runs one turn at a time, across all server instances sharing the storage: a second one is 409 conversation_busy. |
| Logged | Everything that happens is appended to the conversation's event log, readable and followable (GET .../events, .../events/stream). |
| Observable | conversation.runtime says whether a conversation is running, waiting for a human, or idle, and why the last turn stopped. |
| Recoverable | A run whose server died is closed as interrupted; a task left running is failed. |
New in 0.3.0
Before 0.3.0 a streamed turn lived inside its HTTP stream and was dropped when the client went away. Now it runs to its end whatever the client does; a client that relied on a disconnect to stop a turn has to call POST /conversations/:id/cancel. See Upgrading to 0.3.0.
When to use it
Everything on this page is on by default; there is nothing to switch on. It matters when:
- a model call takes a while (long answers, tool loops, an approval that waits) and the person reloads the page or switches tabs - the answer is not lost and the client reattaches to it;
- you offer a Stop button -
POST /conversations/:id/cancelhangs up on the model (with agent-sdk 0.10.6 or later) instead of just closing your ownfetch; - several server instances share one database - one turn at a time per conversation holds across all of them when the storage provider has leases (every built-in provider does);
- you operate the server - the event log,
runtimeand the recovery sweep tell you what a conversation is doing and what happened to the ones whose process died.
Runtime
GET /conversations/:id and every item of GET /conversations carry conversation.runtime:
{
"status": "requires_action",
"stopReason": "requires_action",
"lastEventSeq": 41,
"pendingActionIds": ["ap_9"],
"activeRunId": "run_x1"
}| Field | |
|---|---|
status | idle, running (a turn is executing, here or on another node), requires_action (waiting for a human), budget_paused (the last turn stopped on the conversation's budget and it is still used up, or the turn was cut short in the middle and waits to be continued - which it keeps doing after the budget is raised, until POST /conversations/:id/continue carries it on or a new message gives it up) |
stopReason | Why the last turn stopped: end_turn, requires_action, cancelled, max_turns, limit, budget_reached, error. Absent while a turn is running. |
lastEventSeq | seq of the newest event (0 when there is none): follow from here with ?after= |
pendingActionIds | The actions the conversation waits on |
activeRunId | The running run (the runId of the message response, the run_id of its events) |
error | Why the last turn ended in error, when the log says: { code, message } from its session.error event. code: "run_interrupted" is a run whose server died - the conversation then has the person's message and no answer, and this is how a client tells them why. Absent for every other stop, and gone with the next turn |
The assistant message of every turn records how it ended: message.status (completed, requires_action, incomplete, cancelled) and message.metadata.stopReason. A custom handler or a host agent can report max_turns or limit itself with stopReason in its result; budget_reached is the server's, when the conversation's budget ends a turn.
One turn at a time
POST /api/conversations/conv_1/messages -> 200 (a turn starts)
POST /api/conversations/conv_1/messages -> 409 { "error": { "code": "conversation_busy", "details": { "activeRunId": "run_x1" } } }
POST /api/conversations/conv_1/actions/ap_9 -> 409 conversation_busyThis is the answer to a double click, two tabs, or two servers: it is a real HTTP status even for a streamed request, and nothing is stored for the refused one. The first turn is not affected. With a storage provider that supports leases (all built-in ones) the rule holds across server instances; a lease is renewed while the run is alive, so a crashed server frees its conversations after runs.leaseTtlMs (60 s).
Cancel
POST /api/conversations/conv_1/cancel{
"cancelled": true,
"runId": "run_x1",
"settled": true,
"stopReason": "cancelled",
"runtime": { "status": "idle", "stopReason": "cancelled", "lastEventSeq": 14, "pendingActionIds": [] }
}- A turn is running. It is aborted. The agent gets the abort signal (a model call is cut, a tool that honours
signalstops), the answer streamed so far is kept as the assistant message withstatus: "cancelled", and the request that started the turn ends withstatus: "cancelled"/stopReason: "cancelled"(stream.donefor a stream). An agent that ignores the signal is abandoned afterruns.cancelGraceMs(5 s): whatever it does later changes nothing. The response is200once the run has finished winding down;202withsettled: falsewhen it is still stopping, or runs on another node (that node sees the request at its next lease renewal). - The conversation waits on a human. Its open actions are cancelled: an
action.resolvedevent withcancelled: truefor each, thensession.status_idlewithstop_reason: "cancelled".cancelledActionIdslists them. - Nothing to cancel is not an error:
cancelled: false.
Deleting a conversation that is running cancels its run first.
In an SDK agent, a handler or a host agent
agentServer.registerCustomAgent('slow', {
async processMessage({ message, signal }) {
const res = await fetch(url, { signal }); // stops when the run is cancelled
return { content: await res.text() };
},
});A host-backed agent gets the same signal in io.signal and may implement cancel for a backend that runs elsewhere (Host-backed agents). For agent-sdk agents nothing is needed: the signal is passed as cancellationToken (an AbortSignal is a valid token, and tools receive it). An SDK result with state.ctx.__cancelled is a cancelled turn; state.ctx.__limitBreached ends the turn with stopReason: "limit".
A cancelled run is recorded as cancelled, whichever agent it was. When the person pressed Stop, the turn ends status: "cancelled" / stopReason: "cancelled" (message, runtime, session.status_idle { stop_reason: "cancelled" }, no session.work_done) and keeps the text streamed so far - also when the agent returned an ordinary answer in the same breath, or does not mark its result as cancelled. That holds for an agent-sdk agent, a Chat Completions / Responses agent, a handler and a host agent, on a message, on the answer to an approval and on continue.
The model request is hung up on. Chat Completions and Responses agents pass the signal to fetch. An agent-sdk agent does when its SDK does: agent-sdk 0.10.6 or later passes the cancellation token to the provider request (OpenAI, Azure, Anthropic, Bedrock and Vertex adapters and LangChain models), so the connection to the model is closed at once and the provider stops generating - with an older SDK the model finishes writing its answer into the void and bills for it. Upgrade the SDK in an application that offers Stop.
The event log
Every conversation has an append-only log of what happened in it, in the same vocabulary as Console's session events: snake_case types and data, seq unique and ascending within the conversation.
| Type | data |
|---|---|
user.message | content, message_id, files? (id, name, mime_type, size of each file the message carries) |
session.status_running | trigger: message, resume (an answer, or a continuation) or timeout (a request lapsed and the agent carries on); mode?: regenerate or edit for a regeneration or an edit-and-resend |
agent.tool_use | tool_call_id, name, args |
agent.tool_result | tool_call_id, name, ok, duration_ms?, error? |
agent.message | content, message_id, stop_reason, usage? (input_tokens, output_tokens, total_tokens, cached_input_tokens?, cost_usd?) |
session.usage | input_tokens, output_tokens, total_tokens, cached_input_tokens? and - with pricing - turn_cost_usd, spent_usd, budget? (max_cost_usd, on_reach) |
budget.reached | spent_usd, max_cost_usd, on_reach; just before the session.status_idle with stop_reason: budget_reached |
action.required | action_id, kind (tool_approval | user_question | client_tool), tool_name?, summary?, args?, questions?, allow_free_text?, timeout_seconds?, expires_at? |
action.resolved | action_id, kind, decision?, cancelled?, expired?, remembered?, edited_args?, ok? / error? / duration_ms? (a client tool), status (approved, rejected, answered, failed, cancelled, expired), resolved_by, channel (api, system, timeout or remembered) |
session.truncated | reason (regenerate | edit), from_message_id, removed_count, removed_message_ids (at most 50): messages were removed from the transcript - reload the history |
session.title | title, purpose: "title", model?, usage? (input_tokens, output_tokens, total_tokens), cost_usd?: a generated title and what making it used |
session.status_idle | stop_reason, pending_action_ids?, detail? |
session.work_done | none; right after an idle with stop_reason: end_turn |
session.error | message, code? |
A turn is bracketed by session.status_running and session.status_idle. A new type can appear in a future version: ignore the ones you do not know. Event data over 16 KB (runs.maxEventDataBytes) is cut and carries _truncated: true; the stored message is always whole.
GET /api/conversations/conv_1/events?after=40&types=agent.message,session.status_idle&limit=100{
"events": [
{ "seq": 41, "type": "agent.tool_use", "data": { "tool_call_id": "c1", "name": "search", "args": { "q": "cats" } }, "run_id": "run_x1", "created_at": "2026-10-11T10:00:03.120Z" }
],
"hasMore": false,
"lastSeq": 41
}Oldest first. Pass lastSeq as after for the next page. limit is 100 by default and at most 1000.
Following a conversation
GET /api/conversations/conv_1/events/stream?after=41
Accept: text/event-streamServer-Sent Events. The stream replays the stored events after after (or after the Last-Event-ID header a browser EventSource sends when it reconnects - the header wins), then follows the conversation live. It subscribes before it reads, so an event that is stored while the replay runs is neither missed nor sent twice. Each stored event is a frame:
id: 42
event: agent.tool_use
data: {"seq":42,"type":"agent.tool_use","data":{"tool_call_id":"c2","name":"web_search","args":{"q":"..."}},"run_id":"run_x1","created_at":"..."}data is the same object as an item of the event list. Besides the stored events there are agent.message.delta frames (event: agent.message.delta, data: {"text":"...","run_id":"run_x1"}), the model's text as it is written. They have no id and are not stored. They exist for a turn that was started with stream: true; a turn started without it shows the finished agent.message at once. Treat deltas as a bonus: build a follower around the stored events.
: ping is sent every 15 s (runs.pingIntervalMs); the first line is : open. The stream stays open across turns until the client closes it or runs.maxStreamMs (one hour) passes - reconnect with the last id. With types= only those types (and, if listed, agent.message.delta) are sent.
A client that goes away only closes its own stream. Events written by another server instance reach the stream by polling the log (runs.followPollMs, 1 s).
The snapshot frame
A client that joins while a run is active (a reload, a second window) missed the text the run has already streamed - deltas are not stored. So when the run is on the node that serves the stream, the first frame after : open is one snapshot:
event: agent.message.snapshot
data: {"type":"agent.message.snapshot","data":{"run_id":"run_x1","text":"Hello ","user_message":{"id":"msg_1","content":"say hello","files":[]}}}It has no id: and is not in the log (GET .../events never lists it), so it does not move the cursor; it is sent once per connection (a client that reconnects - an EventSource after a dropped connection - is sent a fresh one: replace the text of the bubble with it, do not append to it). text is the answer as far as it has been streamed ("" before the first token); the agent.message.delta frames that follow continue exactly where it stops - nothing between is missing or repeated - so the view is snapshot.text + every delta. user_message is the message the run is answering (null for a run that answers an action or continues a turn, and for the instant before the message is stored): a client whose stored history does not have it yet shows it. When the run ends, the stored agent.message / session.status_idle events arrive as usual - reload the final message then.
It is sent unless types= is given and names neither agent.message.snapshot nor agent.message.delta. A run on another node cannot be summarised here (its text is not shared), so that stream has no snapshot and follows the stored events.
Reattaching after a reload
GET /conversations/:id- render the history andpendingActions; readruntime.- If
runtime.status === 'running':GET /conversations/:id/events/stream?after=<runtime.lastEventSeq>. Seed the answer bubble from the first frame, the snapshot (and the person's message, if the history lacks it), then append the text deltas and show tool calls and new cards. Whensession.status_idlearrives, reload the final message. Ifruntime.stopReason === 'error'readruntime.error:run_interruptedmeans the server stopped mid-turn and there is no answer to wait for - offer to send the message again. - If it is
requires_action: show the cards and resolve them.
A client that streamed a message and lost the connection uses the seq of the last stream.* frame it saw the same way - see Streaming.
Providers without an event log
capabilities().events is true for the built-in providers. A custom provider that has no events still works: the server keeps the last runs.memoryEventLimit (2 000) events per conversation in memory. Reattach and replay then work while the process lives, on one node, and the log (and its seq) starts over after a restart. See Custom Storage.
Recovery
A run that was executing when its server died never wrote session.status_idle. When the lease it held has expired:

What a client can show for a conversation whose last turn ended stopReason: "error" with runtime.error.code: "run_interrupted" (the standalone example, after its server was killed mid-answer): the person's message has no answer, and sending it again is the way forward.
- the next message to that conversation takes it over and first closes the old run:
session.error(code: "run_interrupted") andsession.status_idlewithstop_reason: "error"; - the sweep does the same for conversations nobody writes to: once at the first request, then every
runs.sweepIntervalMs(60 s). Callserver.recoverStaleRuns()yourself to run it on demand.
The same sweep handles the approvals and questions nobody answered in time (Requests that lapse); server.expirePendingActions() runs that part on demand.
A task left running by a process that is gone is failed ("The server stopped while the task was running"). With leases that means a task nobody has renewed a lease for in runs.leaseTtlMs; without leases the server assumes it is the only node and fails what it finds that is older than itself (runs.assumeSingleNode: false turns that off for a multi-node deployment on a provider without leases).
Events older than runs.eventRetentionDays (30) are pruned by the sweep; a conversation's log is deleted with the conversation. The same sweep deletes the files past their files.retentionDays (at most once per files.purgeIntervalMs); with sweepIntervalMs: 0 call server.purgeExpiredFiles() yourself.
Configuration
createAgentServer({
basePath: '/api',
storage,
runs: {
leaseTtlMs: 60_000, // a crashed server frees its conversations after this long
cancelGraceMs: 5_000, // how long a cancelled run may take to wind down before it is abandoned
cancelWaitMs: 5_000, // how long POST .../cancel waits before answering 202
pingIntervalMs: 15_000, // `: ping` on the event stream
followPollMs: 1_000, // how often a follower looks for events written by another node
maxStreamMs: 3_600_000, // longest an event stream stays open
eventPageSize: 100, // GET .../events default `limit`
maxEventPageSize: 1_000,
maxEventDataBytes: 16_384, // larger event data is cut (`_truncated: true`)
memoryEventLimit: 2_000, // events kept per conversation when the provider has no event log
eventRetentionDays: 30, // 0 keeps them for good
sweepIntervalMs: 60_000, // 0: no periodic sweep
recoverOnStart: true, // recover once at the first request
assumeSingleNode: true, // see Recovery
nodeId: 'web-1', // names this instance in leases (default: random)
},
});await server.close() stops the periodic sweep (and, with { abort: true }, cancels the running turns and waits for them).
Troubleshooting
| Symptom | Cause and fix |
|---|---|
409 conversation_busy that does not go away | A turn really is running: runtime.activeRunId names it, POST /conversations/:id/cancel stops it. After a server crash the conversation is freed once the lease expires (runs.leaseTtlMs, 60 s) - the next message then takes it over. |
202 with settled: false from cancel | The run is still winding down, or runs on another node, which sees the request at its next lease renewal. Poll GET /conversations/:id until runtime.status is no longer running. |
| The model keeps generating (and billing) after Stop | The agent-sdk in use does not pass the cancellation token to the provider request. Use agent-sdk 0.10.6 or later; Chat Completions and Responses agents always hang up. |
events/stream shows nothing after a reload | Read runtime first: if the status is idle there is nothing to follow, reload the history instead. After a restart a provider without a durable event log starts its log over (custom providers; see Providers without an event log). |
| The first frame of a reattached stream is not a snapshot | The run is on another node, or no run is active. The stream follows the stored events instead; reload the final message when session.status_idle arrives. |
| The event stream closes by itself after an hour | runs.maxStreamMs. Reconnect with the last id (Last-Event-ID); an EventSource does it automatically. |
| Frames arrive in lumps behind a proxy | The proxy buffers the response. The Node and Express adapters send X-Accel-Buffering: no; switch buffering off for these routes on your proxy too. : ping every 15 s keeps idle connections open. |
A task stays running after a restart | The recovery sweep fails running and pending tasks that nothing is working on (with leases: quiet for longer than runs.leaseTtlMs). It runs at the first request and every runs.sweepIntervalMs; with the sweep off, call server.recoverStaleRuns(). Without storage leases the server cannot tell a dead task from one on another node: it assumes it is the only node (runs.assumeSingleNode, default true), and with false it leaves such tasks alone. |
| Leases expire early or late across servers | Expiry uses each instance's clock; keep the clocks within a few seconds of each other. |

