Skip to content

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:

DetachedA streamed request is a subscriber of its run. Disconnecting unsubscribes; the run goes on.
CancellablePOST /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 timeA conversation runs one turn at a time, across all server instances sharing the storage: a second one is 409 conversation_busy.
LoggedEverything that happens is appended to the conversation's event log, readable and followable (GET .../events, .../events/stream).
Observableconversation.runtime says whether a conversation is running, waiting for a human, or idle, and why the last turn stopped.
RecoverableA 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/cancel hangs up on the model (with agent-sdk 0.10.6 or later) instead of just closing your own fetch;
  • 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, runtime and 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:

json
{
  "status": "requires_action",
  "stopReason": "requires_action",
  "lastEventSeq": 41,
  "pendingActionIds": ["ap_9"],
  "activeRunId": "run_x1"
}
Field
statusidle, 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)
stopReasonWhy the last turn stopped: end_turn, requires_action, cancelled, max_turns, limit, budget_reached, error. Absent while a turn is running.
lastEventSeqseq of the newest event (0 when there is none): follow from here with ?after=
pendingActionIdsThe actions the conversation waits on
activeRunIdThe running run (the runId of the message response, the run_id of its events)
errorWhy 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 ​

http
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_busy

This 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 ​

http
POST /api/conversations/conv_1/cancel
json
{
  "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 signal stops), the answer streamed so far is kept as the assistant message with status: "cancelled", and the request that started the turn ends with status: "cancelled" / stopReason: "cancelled" (stream.done for a stream). An agent that ignores the signal is abandoned after runs.cancelGraceMs (5 s): whatever it does later changes nothing. The response is 200 once the run has finished winding down; 202 with settled: false when 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.resolved event with cancelled: true for each, then session.status_idle with stop_reason: "cancelled". cancelledActionIds lists 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 ​

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

Typedata
user.messagecontent, message_id, files? (id, name, mime_type, size of each file the message carries)
session.status_runningtrigger: 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_usetool_call_id, name, args
agent.tool_resulttool_call_id, name, ok, duration_ms?, error?
agent.messagecontent, message_id, stop_reason, usage? (input_tokens, output_tokens, total_tokens, cached_input_tokens?, cost_usd?)
session.usageinput_tokens, output_tokens, total_tokens, cached_input_tokens? and - with pricing - turn_cost_usd, spent_usd, budget? (max_cost_usd, on_reach)
budget.reachedspent_usd, max_cost_usd, on_reach; just before the session.status_idle with stop_reason: budget_reached
action.requiredaction_id, kind (tool_approval | user_question | client_tool), tool_name?, summary?, args?, questions?, allow_free_text?, timeout_seconds?, expires_at?
action.resolvedaction_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.truncatedreason (regenerate | edit), from_message_id, removed_count, removed_message_ids (at most 50): messages were removed from the transcript - reload the history
session.titletitle, purpose: "title", model?, usage? (input_tokens, output_tokens, total_tokens), cost_usd?: a generated title and what making it used
session.status_idlestop_reason, pending_action_ids?, detail?
session.work_donenone; right after an idle with stop_reason: end_turn
session.errormessage, 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.

http
GET /api/conversations/conv_1/events?after=40&types=agent.message,session.status_idle&limit=100
json
{
  "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 ​

http
GET /api/conversations/conv_1/events/stream?after=41
Accept: text/event-stream

Server-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 ​

  1. GET /conversations/:id - render the history and pendingActions; read runtime.
  2. 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. When session.status_idle arrives, reload the final message. If runtime.stopReason === 'error' read runtime.error: run_interrupted means the server stopped mid-turn and there is no answer to wait for - offer to send the message again.
  3. 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:

A chat client showing "The reply was interrupted" with a Retry button

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") and session.status_idle with stop_reason: "error";
  • the sweep does the same for conversations nobody writes to: once at the first request, then every runs.sweepIntervalMs (60 s). Call server.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 ​

typescript
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 ​

SymptomCause and fix
409 conversation_busy that does not go awayA 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 cancelThe 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 StopThe 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 reloadRead 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 snapshotThe 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 hourruns.maxStreamMs. Reconnect with the last id (Last-Event-ID); an EventSource does it automatically.
Frames arrive in lumps behind a proxyThe 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 restartThe 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 serversExpiry uses each instance's clock; keep the clocks within a few seconds of each other.

Next Steps ​

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub