A Vercel AI SDK–compliant agent backed by Coder Agents — Coder's server‑side
agent runtime. Call new CoderAgent(...) and get back an object that implements the
AI SDK's Agent interface
(generate() / stream()). Script it, stream from it, and attach your own tools —
exactly like the SDK's own ToolLoopAgent.
Status: targets Coder's stable chat API (
/api/v2/chats), available since Coder 2.37.0 (September 1, 2026). This package remains pre‑1.0. See API compatibility for older deployments.
Coder Agents runs a complete agent loop server‑side — the multi‑step tool loop, built‑in tools, MCP, sub‑agents, multi‑provider model routing, and automatic context compaction. The Vercel AI SDK runs its loop client‑side. This package bridges the two, so a Coder agent looks and feels like a native AI SDK agent without re‑implementing the loop.
Two packages, two jobs:
@coder/ai-sdk-agent(this package) — Coder's server‑side agent: the multi‑step tool loop, built‑in tools, MCP servers, workspace‑scoped file/shell tools, sub‑agents, and compaction all run on the deployment. EachCoderAgentis one server chat ("session") and may provision a workspace. Reach for it when you need server‑side tools, MCP, or a workspace.@coder/ai-sdk-provider— plain model calls through Coder's AI Gateway. A normal AI SDK provider:generateText,streamText, andgenerateObjectfor schema‑constrained structured output. No chat, no workspace, natively cancelable. Reach for it when you just need a model (plan / extract / summarize / classify) with no server‑side tools.
Rule of thumb: need server‑side tools, MCP, or a workspace → Agent; need a model → provider. They compose — a multi‑step pipeline often uses the provider for its pure text/JSON steps and the Agent only for the steps that touch tools.
pnpm add @coder/ai-sdk-agent ai@^7 zodRequires Node ≥ 22 and ai v7 — the constructors throw an actionable error when
another ai major is detected (the guard fails open when the installed version
can't be resolved), instead of failing cryptically mid‑generation.
import { CoderAgent } from "@coder/ai-sdk-agent";
import { tool } from "ai";
import { z } from "zod";
const agent = new CoderAgent({
baseUrl: "https://dogfood.cdr.dev",
token: process.env.CODER_SESSION_TOKEN!, // Coder API/session token
organizationId: "703f72a1-…", // your org UUID
model: "claude-sonnet-4-6", // hint: UUID, provider:model, model id, or display-name substring
instructions: "You are a helpful coding assistant.",
tools: {
getWeather: tool({
description: "Get the weather for a city.",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, tempC: 21 }),
}),
},
});
// Non-streaming
const { text, steps, usage } = await agent.generate({ prompt: "Weather in Paris?" });
// Streaming
const result = await agent.stream({ prompt: "Write a haiku about Coder." });
for await (const delta of result.textStream) process.stdout.write(delta);generate() returns a real AI SDK GenerateTextResult; stream() returns a real
StreamTextResult (so .textStream, .fullStream, .toUIMessageStream(), .steps,
.usage, etc. all work). Because CoderAgent is an Agent, it composes with the rest
of the AI SDK.
Runnable scripts live in examples/ (run against a real deployment via tsx):
export CODER_URL=https://dogfood.cdr.dev
export CODER_SESSION_TOKEN=$(coder tokens create --name coderagent-example)
pnpm example:generate # non-streaming generate()
pnpm example:stream # streaming via textStream
pnpm example:tool # custom (client-executed) tool round-trip
pnpm example:multi-turn # multi-turn session memory
pnpm example:file # attach a file to a chat (optional: pass a path)
pnpm example:structured # typed structured output via the structured_output toolEach example creates a new chat and archives it when done — it never touches workspaces. See
examples/README.md for details.
Tools you pass are registered with Coder Agents as client‑executed ("dynamic") tools.
When the model calls one, the run pauses on the server; the AI SDK runs your tool's
execute, this package submits the result back, and the run resumes. This is the standard
AI SDK tool loop — your execute runs in your process.
- Give tools an
executefor scripting use (the loop runs to completion automatically). - Coder's own server‑side tools (file editing, shell, MCP, …) still run on the server and
appear in the transcript as
providerExecutedtool calls/results — you observe them, you don't execute them.
Migration note: since v0.2.1 server‑executed tools stream with dynamic: true
(they aren't in your ToolSet, and the AI SDK only accepts unknown tool names on
dynamic calls). In UI message streams they therefore surface as dynamic-tool
parts rather than tool-{name} parts — key off toolName, not part.type, when
rendering them.
There are two distinct ways to get a file to the agent, depending on whether the model should read it or operate on it.
Chat attachments — content for the model to read (a PDF, image, CSV…). Drop a native AI SDK
file part into a message and it's uploaded transparently:
import { readFile } from "node:fs/promises";
await agent.generate({
messages: [
{
role: "user",
content: [
{ type: "text", text: "Summarize this report." },
{
type: "file",
data: await readFile("report.pdf"),
mediaType: "application/pdf",
filename: "report.pdf",
},
],
},
],
});Or upload once and reuse across turns with attach() — which also accepts a Blob/File or
stream (use fs.openAsBlob to avoid reading the whole file into memory):
const file = await agent.attach({
content: await openAsBlob("report.pdf"),
mediaType: "application/pdf",
});
await agent.generate({
messages: [
{
role: "user",
content: [
{ type: "text", text: "List the risks." },
file.toFilePart(), // references the upload by id — no re-upload
],
},
],
});Attachments are capped at 10 MiB and restricted to a narrow media‑type allowlist
(application/pdf, application/json, text/{plain,markdown,csv}, image/{png,jpeg,gif,webp}).
Oversized or unsupported files throw a clear error up front.
Workspace files — material for the agent to operate on (a zip of assets, a dataset, a
binary — anything outside the allowlist or over the cap). Write it onto the workspace filesystem
and let the agent's tools take over. This needs a workspaceFiles adapter (the agent core stays
dependency‑free; whoever holds a workspace connection supplies a few‑line adapter):
const agent = new CoderAgent({ /* … */ workspaceId: ws.id, workspaceFiles });
const { path } = await agent.uploadToWorkspace({
content: await openAsBlob("assets.zip"),
path: "assets.zip",
});
// Then ask the agent to `unzip assets.zip` — uploadToWorkspace writes bytes as-is; it does not unpack.Pass a Coder API token or session token as token; it is sent as the
Coder-Session-Token header (REST) and used to authenticate the streaming WebSocket. Create
a token with coder tokens create, or reuse your CLI session.
When baseUrl/token are not passed, they default from the CODER_URL and
CODER_SESSION_TOKEN environment variables (the same convention as
@coder/ai-sdk-sandbox's transports); explicit settings win over the
environment. With the variables set, connection config disappears entirely:
const agent = new CoderAgent({ organizationId }); // uses CODER_URL + CODER_SESSION_TOKENYou can also pass a pre‑built client:
import { CoderAgent, CoderChatClient } from "@coder/ai-sdk-agent";
const client = new CoderChatClient({ baseUrl, token });
const agent = new CoderAgent({ client, organizationId });Coder 2.37.0 promoted chat routes to /api/v2/chats
(server routes,
Go SDK). The promoted routes also
remain under /api/experimental/chats during the compatibility window
(CODAGT-921); legacy removal is tracked in CODAGT-922. Coder 2.36.4 has no stable
chat mount.
Each CoderChatClient selects its chat prefix from HTTP responses, not version
strings. Until selected, an actual REST request (including JSON requests and
Blob uploads) tries v2 first and retries once under the experimental prefix
only on 404. The first non-404 HTTP response
(including an error) locks that prefix for the client's lifetime; concurrent
unresolved calls may probe independently, with the first conclusive response
winning. Two 404s preserve the original v2 error without locking; non-HTTP
failures do not lock either. A locked prefix is never renegotiated, even on 404.
Before opening a per-chat /stream or global /watch WebSocket, an unresolved
client preflights with GET /api/v2/chats using the same selection rules and
cancellation signal.
ReadableStream uploads also preflight so their consumed bodies are never
replayed. Legacy model-config lookup remains independent of chat-prefix
selection (see Configuration).
One CoderAgent instance maps to one chat ("session") on the Coder server. The chat is
created on the first turn and reused for subsequent generate()/stream() calls (multi‑turn
conversation with server‑side history). agent.chatId is the current chat id.
agent.resetSession()— start a fresh chat on the next turn (reuse one instance for sequential turns; you don't need a new agent per turn).agent.interrupt({ signal? })— interrupt an in‑flight generation.agent.archive({ signal? })— archive the underlying chat (cleanup; see Cleanup).agent.listModels()— list the organization's model configs, so you don't have to guess themodelhint.- Resume a prior chat:
new CoderAgent({ …, chatId: "…" })— optionally withlastSeenMessageId(the persisted resume cursor, read fromagent.lastSeenMessageId) to skip the resumed turn's pre‑prompt history probe. See Durable workflows for the full resumption how‑to.
Interrupting is asynchronous on the server: interrupt() resolves as soon as the
interrupt is acknowledged, and the run keeps winding down for a few seconds
afterwards. The client‑level client.interruptChat(chatId, { wait: true }) sends
?wait=true to ask the server to hold the response until the run has stopped —
current Coder servers ignore the unknown parameter and still return immediately,
so confirm completion via the event stream (e.g. watchChats)
rather than relying on it.
A single instance is single‑flight — don't run concurrent generations against it. For concurrency, use one instance per session (and see Workspaces & quota).
Chat history lives on the server. To render an existing chat in a UI (e.g. after
a reload), fetch its messages with the CoderChatClient (agent.client, or one
you construct — see Auth) and convert them with
chatMessagesToUIMessages — the mapping mirrors what a live‑streamed transcript
of the same turn looks like:
import { chatMessagesToUIMessages } from "@coder/ai-sdk-agent";
const { messages } = await client.getMessages(chatId);
const uiMessages = chatMessagesToUIMessages(messages);
// e.g. in React: useChat({ messages: uiMessages })The converter sorts by message id, so the endpoint's newest‑first default page
order (and any pagination order) is safe to pass straight in — useChat always
receives a chronological transcript.
Tool calls become dynamic-tool parts with their results folded in, source
parts become source-url parts, and unknown part kinds are skipped silently, so
history written by newer Coder servers degrades gracefully. One caveat: history
does not record which tool names were client (ToolSet) tools, so every tool
call rehydrates as dynamic-tool — live, client tools stream as statically
typed tool-{name} parts. Render tools by name (ai's
isToolOrDynamicToolUIPart and getToolOrDynamicToolName) rather than by
exact part.type and the difference disappears. Persisted file
parts carry only a file_id (no bytes, usually no URL), so pass a fileUrl
resolver to keep attachments visible — download the bytes with
client.getChatFile(fileId) and return a data:/object/proxy URL; parts that end
up without a URL are skipped:
chatMessagesToUIMessages(messages, {
fileUrl: (part) => (part.file_id ? `/api/files/${part.file_id}` : undefined),
});client.watchChats({ signal }) yields lifecycle events (status/title changes,
creation, deletion, …) for every chat visible to the authenticated user as an
async iterable, backed by the /api/v2/chats/watch WebSocket:
for await (const event of client.watchChats({ signal })) {
if (event.kind === "status_change") console.log(event.chat.id, event.chat.status);
}After prefix selection (whose preflight errors propagate),
this is a long‑lived subscription: dropped connections are redialed automatically
with exponential backoff (1s doubling to a 30s cap, reset once an event arrives).
Aborting the signal or ending iteration tears down the reader. A 4xx upgrade
rejection throws a terminal CoderApiError — bad/expired token, or an older
Coder server without the endpoint (404). For custom plumbing (own client,
browser sockets), the standalone
watchChatEvents({ baseUrl, token, signal, webSocketFactory }) export defaults
to the stable prefix without preflighting; use CoderChatClient for automatic
prefix selection.
onTransportEvent receives typed transport events — HTTP exchanges, the
per‑chat stream's WebSocket lifecycle, and turn‑segment boundaries — so timing
and tracing need no fetch/webSocketFactory wrapping and no re‑parsing of
stream frames. Pass it on CoderAgentSettings (it reaches both the client and
the model), on CoderChatClientOptions, or on CoderLanguageModelConfig:
import { CoderAgent, type CoderTransportEvent } from "@coder/ai-sdk-agent";
const events: CoderTransportEvent[] = [];
const agent = new CoderAgent({
baseUrl,
token,
organizationId,
onTransportEvent: (ev) => events.push(ev),
});
await agent.generate({ prompt: "…" });
// e.g. attribute where the turn spent its time:
for (const ev of events) {
if (ev.type === "http:response")
console.log(
`${ev.op}: ${ev.method} ${ev.path} → ${ev.status} in ${ev.durationMs.toFixed(0)}ms`,
);
if (ev.type === "ws:event" && ev.event.type === "action_required")
console.log(`tool calls arrived at +${ev.timestamp - events[0]!.timestamp}ms`);
if (ev.type === "segment:settle")
console.log(`segment ${ev.segment}: ${ev.status} in ${ev.durationMs.toFixed(0)}ms`);
}CoderTransportEvent is a discriminated union on type. Every event carries
timestamp (Date.now() at observation — comparable to server‑side timestamps
such as a message's created_at, for delivery‑lag measurements):
| event | when | payload (besides timestamp) |
|---|---|---|
http:request |
a REST request is sent | id (correlates the pair), op (the client operation, e.g. "createChatMessage"), method, path |
http:response |
response headers arrive (incl. non‑2xx, ok:false) |
id, op, method, path, status, ok, durationMs |
http:error |
the fetch itself rejects (network failure, abort) | id, op, method, path, message, durationMs |
ws:dial |
a stream connection attempt starts | chatId, reader (identifies the streamChatEvents call), attempt (1‑based per reader, increments per redial), url |
ws:open |
the WebSocket handshake completes | chatId, reader, attempt |
ws:event |
a decoded stream event arrives | chatId, reader, attempt, event (the decoded ChatStreamEvent, by reference — don't mutate), forwarded (the reader's replay verdict) |
ws:close |
the connection ends (exactly one per dial) | chatId, reader, attempt, code/reason when the server/network closed it; absent when the reader closed it (settle, teardown, redial) |
ws:error |
a socket error or unparseable frame | chatId, reader, attempt, message |
ws:redial |
a dropped connection is about to be redialed | chatId, reader, attempt (the ended connection), consecutiveFailures, maxConsecutiveFailures, backoffMs |
segment:start |
a turn segment (one model round‑trip) starts | segment (1‑based per model instance), chatId (absent before the first turn creates the chat) |
segment:settle |
the segment ends (exactly one per start) | segment, chatId, reader (the reader that served the segment; absent if none was acquired), durationMs, and: status + finishReason on a clean settle, error ({name, message}, plus status if the run still settled terminally) on failure, neither on a teardown |
Semantics worth knowing:
- Isolation — exceptions thrown by the handler are swallowed; they can never alter transport behavior or a turn's outcome.
- Zero overhead — without a handler, no event objects are allocated and no extra socket listeners are registered.
- No secrets — events carry no headers and no tokens (auth travels in the
Coder-Session-Tokenheader, which is deliberately excluded);path/urlnever contain credentials. http:*events name their operation:opis the publicCoderChatClientmethod performing the exchange ("createChat","createChatMessage","getMessages","submitToolResults", … — theCoderClientOperationunion), so per‑operation classification never has to reverse‑engineerpath.method/pathstay for generic consumers;archiveChatstamps its ownopeven though it issues the samePATCHasupdateChat. WebSocket prefix-selection preflights emit the existinghttp:*event kinds withop: "streamEvents"orop: "watchChats"— no new event kinds.ws:eventfires at arrival: after a redial, chatd's replay of the in‑progress episode is visible here (correlate withreader/attempt), stamped with the reader's own replay verdict —forwarded: falseexactly on the duplicate deltas the reader suppresses from the turn, so subscribers never re‑derive the episode filter.forwardeddoes not reflect snapshot dedup: repeated or revisedmessagesnapshots are alwaysforwarded: true, because reconciling them is deliberately the consumer's job past the transport layer —TurnTranslator's per‑message ledger decides what a revision re‑emits, and that disposition is not stamped on transport frames. Usews:eventfor span pairing and replay accounting; subscribers needing content fidelity must consume model output (orTurnTranslator), not transport frames.- Every
ws:*event carriesreader— a monotonic id for thestreamChatEventscall (reader) behind the connection, allocated from one process‑wide counter so it stays unique across model and client instances.attemptrestarts at 1 per reader, so identify a connection as(chatId, reader, attempt), never(chatId, attempt)alone: a client‑tool pause the caller abandons is closed fire‑and‑forget when the next turn dials its replacement, and the superseded reader's latews:close(or a raced‑in frame) can emit after the new reader'sws:dial— the reader id is what tells them apart.segment:settlenames the reader that served the segment;segment:startpredates stream acquisition and carries none. - A multi‑step turn that drives client tools emits one
segment:start/segment:settlepair per round‑trip, all riding onews:dialed connection — the stream is retained acrossrequires_actionpauses. A pause settles withstatus: "requires_action",finishReason: "tool-calls"; the final settle carries the terminal status (waiting/completed/error). ws:*events cover the per‑chat/streamreader (turn transport). ThewatchChatssubscription is not instrumented.- With a pre‑built
clientinCoderAgentSettings, HTTP/WS events come from the hook given to that client's options; the agent‑level hook then only receivessegment:*events.
Pass an abortSignal to generate()/stream() to cancel a turn. Aborting
interrupts the server‑side run (not just the local socket), so the chat stops
generating and releases its resources instead of running on, orphaned. Tearing
down a stream() early (cancelling the stream) interrupts the run too.
For a hard ceiling, set requestTimeoutMs. If a segment runs longer (e.g. the
server is wedged, or a workspace can't be scheduled), the run is interrupted and
the call rejects with a retryable CoderChatError (kind: "timeout") instead of
hanging:
const agent = new CoderAgent({ /* … */ requestTimeoutMs: 120_000 });requestTimeoutMs bounds each server segment — one model round‑trip until it
settles or pauses for a client tool. A multi‑step generate() that drives client
tools runs several segments, so it bounds each one, not the whole call. To cap the
total wall‑clock of a multi‑step call, pass a deadline as the signal instead:
await agent.generate({ prompt: "…", abortSignal: AbortSignal.timeout(120_000) });If the event stream drops mid‑turn, the agent redials it automatically with
exponential backoff, replaying the turn's events from its starting cursor and
deduplicating them on receipt — the server keeps generating during the gap, so
a transient drop costs nothing and the run is not interrupted. Only when the stream cannot be re‑established (several
consecutive failed attempts, ~15s) is the server run interrupted and the call
rejected with a CoderStreamError — an AI SDK APICallError. When the failed
turn had just created its chat AND had no external effects a replay would
repeat — no workspaceId, no mcpServerIds, and no freshly uploaded inline
attachments (pre‑uploaded fileId references are fine) — the dead session is
discarded and the error is isRetryable: true, so generate() calls with
maxRetries set retry the whole turn on a fresh chat automatically. Otherwise
the error is isRetryable: false and retrying is the caller's deliberate
decision: on a chat with prior state (resumed sessions, later turns,
tool‑result segments) a re‑invocation would resubmit the same prompt as a new
user turn, workspace/MCP tools may already have executed side effects, and
inline attachments would upload again. (For stream(), a mid‑stream failure
surfaces on the stream itself, outside the SDK's retry wrapper — handle it in
your consumption loop.)
A non‑transient 4xx upgrade rejection (bad/expired token,
deleted chat) fails fast with a CoderApiError instead of retrying; 408/425/429
consume the redial budget like any other transient failure.
archive() soft‑hides the chat (it stays in listings as archived: true; there
is no hard delete yet). A freshly interrupted chat keeps winding down server‑side
for a few seconds, during which archiving 409s — archive() retries those 409s
(~1s apart, up to ~15s overall; tune with settleDeadlineMs /
settleRetryDelayMs) and rethrows the last one if the chat never settles. Any
other failure, including your own abort, rethrows immediately.
archive() and interrupt() target the session's chat — or, after the session
was dropped (resetSession(), or the automatic discard after a fresh‑chat
stream failure — see Handling errors), the last‑known
chat id (agent.lastKnownChatId), so a stranded chat is still cleaned up
instead of leaking. Generation never uses the last‑known id: a turn after a
drop creates a fresh chat as always. Every chat stranded by an automatic
discard is also recorded on a ledger (agent.strandedChatIds, oldest first) —
with maxRetries several failed attempts can strand one chat each while only
the final attempt's error surfaces — and one archive() retires them all,
oldest first, alongside its primary target. Both methods report what they
acted on instead of silently no‑oping: archive() resolves
{ archived: true, chatId, archivedChatIds } (each archived id is cleared as
a cleanup target) or { archived: false } when no chat exists at all;
interrupt() resolves { interrupted: true, chatId } /
{ interrupted: false } the same way. Deliberate abandonment is different:
resetSession() does not add to the ledger (you may want that chat kept),
so after a manual reset the old chat is targetable only until a new chat
supersedes lastKnownChatId.
To make cleanup ride scope exit instead of a finally you have to remember, the
agent is an async disposable:
await using agent = new CoderAgent({/* … */});
const { text } = await agent.generate({ prompt: "…" });
// agent.interrupt() + agent.archive() run automatically when the scope exits.Disposal interrupts any in‑flight run, then archives. It is bounded and never
throws (~15s overall, best‑effort): disposal errors are swallowed so they can't
mask the scope's own error. Call archive() directly when you need guaranteed
cleanup.
In a request handler that returns before a fire‑and‑forget archive() settles, the
archive can be abandoned — await using (or an awaited archive() in finally)
avoids accumulating live chats.
All errors extend CoderAgentError, except CoderStreamError (below). Two
carry structured detail you can branch on:
CoderApiError— an HTTP request failed. Fields:status,method,path,detail.CoderChatError— a turn ended in an error, timed out, or lost its stream. Fields:kind,retryable,statusCode,provider.CoderStreamError— the event stream dropped and could not be re‑established within its redial budget. Extends the AI SDK'sAPICallError(notCoderAgentError), sogenerate()'smaxRetriesmachinery recognizes it.isRetryableistrueonly when the failed turn created its chat (the dead session is discarded, so a retry starts fresh) and had no external effects to repeat (workspace/MCP tooling, fresh attachment uploads); otherwise it isfalse— a retry would resubmit the prompt as a new user turn and could duplicate those effects. A failure mid‑stream()surfaces on the stream, outside the retry wrapper. The last transport failure is incause, andchatIdnames the chat the failed turn had created or attached to (absent when it failed before a chat existed) — after the fresh‑chat discard the chat still exists server‑side, andarchive()keeps targeting it viaagent.lastKnownChatId(Cleanup).
import { CoderApiError, CoderChatError } from "@coder/ai-sdk-agent";
try {
await agent.generate({ prompt: "…" });
} catch (err) {
if (err instanceof CoderChatError && err.retryable) {
// transient (timeout, stream_closed, an upstream 5xx) — back off and retry
} else if (err instanceof CoderApiError && err.status === 429) {
// rate limited
} else {
throw err;
}
}maxRetries defaults to 0: this agent owns server‑side chat state, so an
SDK‑level retry could duplicate a turn. Prefer catching retryable errors and
retrying the whole step deliberately.
Results carry normalized token usage in usage. A chatd turn runs several
model steps server‑side (one per server tool round), each reporting its own
usage — the SDK sums every step, and the AI SDK adds up the steps of a
turn that paused for client tools, so result.usage reflects what the whole
turn actually consumed. inputTokens is the full prompt size: Coder
normalizes the wire input_tokens to the uncached count (cache reads/writes
are separate fields), so the SDK adds them back into the total and exposes the
split via inputTokenDetails (noCacheTokens, cacheReadTokens,
cacheWriteTokens).
The snake_case wire usage lives per step at result.steps[i].usage.raw
(the AI SDK does not carry raw onto the summed result.usage) for fields
the normalized shape has no slot for (context_limit, cost, runtime, and any
newer wire fields, which pass through newest‑value‑wins). raw keeps the wire
convention (input_tokens = uncached only) with counters summed over that
step's server‑side model steps and context_limit from the newest one — so
don't divide raw's summed counters by context_limit to estimate context
fullness; they are turn consumption, not a prompt‑size snapshot. When the
server reports them, total_cost_micros (micro‑USD) and total_runtime_ms
are mirrored the same way under each step's providerMetadata.coder
(result.providerMetadata reflects only the final step — sum
result.steps[*].providerMetadata.coder for whole‑turn cost when client tools
ran). Both are absence‑tolerant mirrors: on servers that don't send them
(cost is otherwise only on the aggregate cost endpoints,
/api/v2/chats/cost/*), nothing is emitted.
Forward usage to a UI via message metadata:
const result = await agent.stream({ prompt: "…" });
return result.toUIMessageStream({
messageMetadata: ({ part }) =>
part.type === "finish-step"
? { usage: part.usage, coder: part.providerMetadata?.coder }
: undefined,
});Model configs with web search enabled emit source parts. These flow through to
result.sources and, in UI message streams, source-url parts (pass
sendSources: true to toUIMessageStream — the AI SDK omits them by default).
Earlier releases dropped them.
Coder Agents has no server‑side response_format, so CoderAgent cannot
constrain what the model says to a JSON schema — a responseFormat /
experimental_output request emits a warning and is best‑effort at most. Pick
by what the step needs:
- Pure text‑in / JSON‑out, no server‑side tools → use
@coder/ai-sdk-providerwithgenerateObject/Output.object(schema‑constrained; requires AI Gateway on the deployment). - The answer must come out of an agent run (server‑side tools, MCP, a
workspace) → use the
structured_outputtool pattern below. What the model says isn't schema‑constrained, but what it passes into a tool is typed — so have it submit its answer by calling a tool whoseinputSchemais your Zod schema. The answer arrives as the tool call's typedinput; no fishing JSON out of prose.
import { stepCountIs, tool } from "ai";
import { z } from "zod";
const Answer = z.object({ severity: z.enum(["critical", "major", "minor"]), summary: z.string() });
const agent = new CoderAgent({
/* … */
instructions: "… Submit your final answer by calling the structured_output tool exactly once.",
tools: {
structured_output: tool({
description:
"Submit your final structured answer as JSON. Call this exactly once, when your work is complete.",
inputSchema: Answer, // your schema IS the tool's input schema
// Ack instead of stopping the turn: the model finishes naturally and can
// wind down anything it still has running (dev servers, watchers, …).
execute: async () =>
"Output received. Wind down and end your turn. Do not call structured_output again.",
}),
},
stopWhen: stepCountIs(6), // happy path is 2 steps: file + ack, wind down
});
const result = await agent.generate({ prompt: "…" });
// toolCalls only holds the LAST step's calls — scan all steps. Take the last call
// that VALIDATES: a schema-invalid re-file must not shadow a valid answer (rule 2).
const filed = result.steps
.flatMap((s) => s.toolCalls)
.filter((c) => c.toolName === "structured_output");
let answer: z.infer<typeof Answer> | undefined;
for (const call of filed.reverse()) {
const parsed = Answer.safeParse(call.input);
if (parsed.success) {
answer = parsed.data; // typed: { severity: "critical" | "major" | "minor"; summary: string }
break;
}
}
if (answer === undefined)
throw new Error("no valid structured_output call — nudge once on an idle chat (rule 3)");Rules that keep it robust — each guards against a failure mode observed live:
- Don't force
toolChoice, don't stop on the call.toolChoiceis construction‑time and applies to every segment, so after the ack it would force the tool again and again up to the step ceiling (and it blocks any other tools the step needs). AhasToolCallstop is worse: the server only receives a client tool result as a side effect of the next loop segment, so ending the loop on the call strands the chat inrequires_action— follow‑up messages queue forever andarchive()409s. Instructions plus the tool's own description are enough; models file unprompted most of the time. - Validate client‑side. The schema is not enforced server‑side —
schema.safeParseon the tool input is the real gate. (Schema‑invalid calls that the AI SDK catches in‑loop are automatically answered with atool-errorresult the model retries against.) - Nudge at most once, and only an idle chat. If the turn ends in prose
(
finishReason: "stop") without a valid call, send one typed re‑prompt ("Call the structured_output tool now …"), then fail into your normal error handling. Never re‑prompt a chat that isn't idle — the message would queue behind whatever the server is still doing. - Settle a turn that stopped on a tool call. If the loop stops on a
tool‑call step — e.g. your
stopWhenceiling lands exactly on thestructured_outputcall (finishReason: "tool-calls") — the tool results ran locally but never reached the server. Guard onagent.chatId(it isundefineduntil the first turn creates the chat), then submit the stranded step's (result.steps.at(-1)) locally‑executed client outcomes directly viaagent.client.submitToolResults(chatId, { results: [{ tool_call_id, output, is_error }] }, AbortSignal.timeout(8_000))before touching the chat again, or it strands as in rule 1. Read the outcomes off the step's content parts: atool-resultpart is a success, atool-errorpart (the tool'sexecutethrew) must be submitted withis_error: true— mirroring what the resume path would have sent. If a pending call has no local outcome (or the submit fails), end the stranded turn withagent.client.interruptChat(chatId, AbortSignal.timeout(8_000))instead. Bound every one of these recovery requests with anAbortSignal— they target a server that may already be stalled, and the bareagent.interrupt()/agent.archive()helpers carry no timeout. A settled chat resumes its wind‑down server‑side for a few seconds, so retry a 409ing archive (agent.client.archiveChat(chatId, signal), per‑attempt bound) under a short deadline instead of giving up.
examples/06-structured-output.ts packages
all four rules into a small copyable helper — structuredOutput(schema) returns
agentOpts to spread into the constructor plus a typed ask(agent, prompt)
that runs the settle + one‑nudge ladder and returns a z.infer<typeof schema>.
Compose additional client tools through the helper —
structuredOutput(schema, { tools: { myTool } }) merges them into one ToolSet —
rather than passing tools: to the constructor next to the spread, where the
later key silently clobbers the other map.
When the agent is bound to a workspace (the workspaceId setting), you can
resolve — and share — the browser URL where a port on that workspace is served,
e.g. the dev server the agent just started:
const { url } = await agent.getPreview({ port: 3000 });
// → https://3000--main--dev--alice.apps.example.com (private to the workspace owner)
const shared = await agent.sharePreview({ port: 3000, shareLevel: "authenticated" });
// shared.url is now reachable by any logged-in user; shared.shareLevel is the level in effectBoth are built on the stable v2 workspace APIs (workspace lookup + the wildcard
apps host; sharePreview adds a port‑share upsert), so they work against old
Coder servers — no experimental endpoints.
getPreview({ port, agentName?, protocol?, signal? })composes the subdomain URL. The URL honors the port's current share level — private to the workspace owner unless shared.agentNameis optional when the workspace has exactly one agent (with several, the error lists the candidates);protocol: "https"means the app speaks TLS inside the workspace (it adds theslabel suffix,3000s--…) and does not affect the browser scheme.sharePreview({ port, shareLevel?, … })additionally upserts the port's share level (re‑invoking updates it in place) and returns the level in effect.shareLevelis"authenticated"(any logged‑in user; the default),"organization"(members of the workspace's organization; requires a newer Coder server), or"public"(no auth at all — mind what the port serves). Reverting to owner‑only means deleting the share;"owner"is not accepted on upsert.- Clear failures instead of broken URLs: a deployment without a wildcard access
URL (
--wildcard-access-url) yields an explanatory error, and a server that predates port sharing (< Coder v2.9) yields a 404CoderApiErrorsaying so. Ports below 1000 are rejected up front for the same reason — Coder subdomain URLs only encode 4–5 digit ports, so80--agent--…would be parsed as an app named "80" and never resolve; serve the preview on a higher port.
The preview helpers call non‑chat endpoints, so they need baseUrl + token
credentials — pass them alongside client if you construct one yourself (or
let them default from CODER_URL/CODER_SESSION_TOKEN).
A CoderAgent is one server‑side chat, and — depending on its configuration and
the deployment — a chat may be backed by a Coder workspace that runs its
tools. Workspaces are the scarce resource: a deployment budgets how many an
account may run at once, so N agents running concurrently can need N
schedulable workspaces. Past that bound, a turn can sit unscheduled and never
settle. This section is the operational guide for running fleets of agents:
how the binding works, how to size concurrency, what to clean up, and how to
diagnose a chat that is stuck.
- One chat, at most one workspace, fixed at creation.
workspaceIdis sent asworkspace_idwhen the chat is created; nothing can rebind it afterwards — message and update requests carry no workspace field. To move work to another workspace, start a new agent/chat. - This SDK never provisions workspaces. A
workspaceIdyou pass must be an existing workspace (provision one with@coder/ai-sdk-sandbox'sensureCoderWorkspace, the CLI, or the v2 API). A chat created withoutworkspaceIdcan still come back workspace‑backed — deployments may assign one server‑side; the SDK reads the created chat'sworkspace_idand treats the chat as workspace‑backed from then on (which matters for retry ownership, below). - Chat cleanup does not release the workspace.
archive()soft‑hides the chat only; the workspace keeps running until template autostop or an explicit stop — and stopping releases only stop‑scoped quota; persistent resources (disks, volumes) keep consuming their cost until the workspace is deleted.
The structural rule: workspaces that must be running concurrently ≤
schedulable workspaces. With one workspace per chat — the common fleet shape,
and what a deployment that auto‑assigns workspaces produces — that means
concurrent chats ≤ schedulable workspaces. Chats explicitly bound to a shared
workspaceId count that workspace once, so their concurrency is not
quota‑bound — at the price of sharing one filesystem and tool environment,
which is only acceptable within a single tenant / trust boundary.
What counts as "schedulable" is a deployment property, not an SDK knob —
whichever of these binds first:
- Workspace quota (premium deployments). Templates declare per‑resource
costs; a user's budget is the sum of their groups' quota allowances, enforced
when a workspace build starts or stops. A start that would exceed the budget
fails the build (error code
INSUFFICIENT_QUOTA, "insufficient quota"), so the turn never gets its workspace — see resource quotas. Note that a stopped workspace typically still consumes its persistent resources' cost, so a fleet that only ever stops (never deletes) scratch workspaces converges on a full budget. - Infrastructure. Without quotas there is no per‑user workspace limit by default (workspace lifecycle) — the bound is provisioner throughput and cluster capacity, and exceeding it looks like slow or failing builds rather than a crisp quota error.
Practical sizing:
- Read headroom before fanning out:
GET /api/v2/organizations/{org}/members/{user}/workspace-quotareturns{ "credits_consumed": …, "budget": … }. Quota is denominated in credits, not slots: admit another workspace only whilebudget − credits_consumedcovers that workspace's cost (the sum of its template'sdaily_costdeclarations). "Free slots = headroom ÷ cost" only holds for a homogeneous fleet on one template; with mixed templates, size against each planned workspace's own cost. - Keep fan‑out width within that headroom and queue the rest client‑side — an unschedulable turn does not queue usefully on the server (see Preventing stuck turns).
- Reuse one bound workspace across sequential turns and sessions instead of provisioning per request — the workspace is the expensive part, the chat is cheap. Reuse only within one tenant / trust boundary: workspace‑bound agents have file and shell tools, so a reused filesystem carries one session's artifacts (and secrets) into the next — provision per tenant, or securely reset a workspace before reassigning it.
- Steps that don't need server‑side tools belong on the provider — it never touches a workspace.
Two lifetimes to manage, separately:
- Chats —
archive()/await usingevery agent (Cleanup), or finished chats keep holding server resources. - Workspaces — rely on template‑level scheduling rather than manual
hygiene:
- Autostop TTL. Give fleet templates a default TTL long enough to survive
a normal session (including idle gaps between turns), short enough that a
leaked workspace stops burning running‑cost within hours — without
autostop, a leaked workspace pins its full quota until someone notices.
Note that stopping only releases the quota of resources that go away on
stop; persistent resources (disks, volumes) keep consuming their
daily_cost, so a scratch fleet that only ever stops still converges on a full budget — pair the TTL with dormancy auto‑deletion or explicit deletion. With@coder/ai-sdk-sandbox,stopAfter: "8h"sets the TTL (ttl_ms) at creation. - Activity bump (default 1 h) extends a running workspace's deadline when Coder detects sessions — check what counts as activity before assuming an agent's server‑side tool use keeps its workspace alive.
- Dormancy / failure cleanup reap abandoned and repeatedly‑failing workspaces automatically — see template scheduling.
- Autostop TTL. Give fleet templates a default TTL long enough to survive
a normal session (including idle gaps between turns), short enough that a
leaked workspace stops burning running‑cost within hours — without
autostop, a leaked workspace pins its full quota until someone notices.
Note that stopping only releases the quota of resources that go away on
stop; persistent resources (disks, volumes) keep consuming their
The signature failure mode of an over‑committed fleet: the chat is created, the stream opens, and then — nothing. There is no distinct "quota exceeded" error kind on the chat stream; a chat whose workspace can't be scheduled surfaces either a generic turn error or, worse, a chat that sits in a non‑terminal status indefinitely. Defend in this order:
- Set
requestTimeoutMs— always, in fleets. It is unbounded by default. On expiry the call rejects with aCoderChatError(kind: "timeout",retryable: true) so your dispatcher gets its slot back, and a server‑side interrupt is fired best‑effort — fire‑and‑forget, and unreachable when the timeout lands before chat creation returned an id — so the run usually stops, but is not guaranteed released (Timeouts). Pair it with reconciliation: periodically sweep for non‑terminal chats older than your budget and interrupt/archive them. - Cap total wall‑clock with
abortSignal: AbortSignal.timeout(…)—requestTimeoutMsbounds each segment, not a whole multi‑tool call. - Own the retries. Workspace‑backed turns are never auto‑retried: the
SDK marks a stream‑loss error
isRetryableonly when the failed turn created its chat and had no workspace, no MCP servers, and no fresh uploads (Timeouts). Your dispatcher owns the retry decision — and should re‑check quota headroom first, or it re‑queues into the same wall. - Watch the fleet.
watchChatsyields status changes for every chat visible to the authenticated user — with chats spread across per‑tenant credentials, run one watcher per identity (or watch with a credential that can see them all). Alert on chats sitting in a non‑terminal status (e.g.pending) longer than yourrequestTimeoutMs. Events alone aren't a complete monitor: reconnects resubscribe fresh (no cursor or replay), so a chat already stuck before the watcher started — or one that transitioned during a gap — never emits an event to start your timer from. Seed and periodically reconcile against a chat listing (GET /api/v2/chats) instead of trusting the event stream alone.
Wire onTransportEvent into fleet telemetry — the event
sequence pinpoints where a turn died:
| symptom (transport events) | likely cause | fix |
|---|---|---|
segment:start and ws:open fired, initial status events, then silence — no message_part, no segment:settle |
if the chat's status is pending (or its workspace build is pending/failed): the workspace can't be scheduled — quota exhausted, no free provisioner, failing template. If the status is running with a healthy build, it may just be a slow model/tool step — silence alone doesn't prove scheduling |
check quota headroom (workspace-quota endpoint) and the workspace's build — a quota failure logs INSUFFICIENT_QUOTA; reclaim enough credits for the planned build (stopping helps only for stop‑scoped costs — delete workspaces to reclaim persistent costs, or raise the group allowance) and set requestTimeoutMs so this fails loudly next time |
segment:settle carries error: { name: "CoderChatError", message: "…requestTimeoutMs budget…" } |
the per‑segment bound expired — wedged server, slow model, or an unschedulable workspace | inspect the workspace via the v2 API/UI: a pending/failed build means the row above; running means the turn was genuinely slow — raise requestTimeoutMs for long tool work |
repeated ws:redial with consecutiveFailures climbing toward maxConsecutiveFailures (5; backoff 1 s → 2 s → 4 s → 8 s, ≈15 s of redialing without forward progress), then a CoderStreamError |
the network path to the deployment is failing — not workspace scheduling (the server keeps generating through short gaps) | fix connectivity; mind retry ownership above — on workspace‑backed chats the error is isRetryable: false, so the replay decision is yours |
segment:settle with status: "error" and an error payload |
the turn failed server‑side — a provider/model error, a tool failure, or a scheduling/build failure that terminated the turn instead of leaving it pending | the settle event's error carries only { name, message } — branch on kind / retryable / statusCode by catching the thrown CoderChatError: at the generate() call site, or — for stream() — around stream consumption, since mid‑stream failures surface on the stream, not from await agent.stream() (Handling errors); check the workspace build state to rule scheduling in or out |
turn settled status: "requires_action", follow‑up messages queue forever |
the loop ended on an unanswered client tool call | submit the stranded results or interrupt — see rule 4 under Structured output; if a crash left the pause behind, reconcile effects first (Make client tools crash-safe) |
archive() keeps returning 409 and rethrows after ~15 s |
the chat never settled server‑side — usually a stuck run still holding its workspace | interrupt() with a bounded signal, then re‑archive; if the run stays wedged, stop the workspace itself |
CoderAgentSettings:
| field | description |
|---|---|
client | (baseUrl + token) |
connection (one or the other; baseUrl/token default from CODER_URL/CODER_SESSION_TOKEN) |
organizationId |
org UUID that owns the chat (required) |
model |
model hint: UUID, provider:model, model id, or display‑name substring |
reasoningEffort |
reasoning effort for chat creation and every user-message submission (see below) |
instructions |
system prompt |
tools |
AI SDK ToolSet (client‑executed) |
workspaceId |
bind the chat to a Coder workspace (enables workspace‑scoped tools) |
workspaceFiles |
adapter enabling uploadToWorkspace() (write files to the workspace FS) |
mcpServerIds |
server‑side MCP servers to enable |
planMode |
enable plan mode ("plan") |
stopWhen |
AI SDK stop condition(s); default stepCountIs(64) |
maxRetries |
default 0 — SDK retries can duplicate server‑side turns; override with care |
requestTimeoutMs |
per‑turn time budget (ms); interrupts the run and rejects (kind: "timeout") instead of hanging |
onTransportEvent |
observability hook for typed transport events (see Observability) |
settleDeadlineMs |
overall deadline for bounded cleanup (archive() 409 retries, disposal); default 15 000 |
settleRetryDelayMs |
pause between archive() retries while the chat settles; default 1000 |
chatId |
resume an existing chat |
lastSeenMessageId |
resume cursor for chatId (persist agent.lastSeenMessageId) — skips the resumed turn's pre‑prompt history probe |
The model hint resolves against the organization's model configs
(GET /api/v2/organizations/{organizationId}/chats/models, with the provider
type joined from the response's provider descriptors) in order: a config UUID
is used as‑is, then an exact provider:model match, an exact model id, a
display‑name substring (case‑insensitive), and finally a model‑id substring.
On older deployments where the organization‑scoped route does not exist yet
(404), resolution falls back once to the legacy deployment‑wide
/api/experimental/chats/model-configs listing, independently of chat-prefix
selection. Partial payloads from older/newer servers are tolerated (entries
match on the fields they carry), and an unresolvable hint falls back to the
server's default model instead of failing. Use
agent.listModels() to see what's available.
Set reasoningEffort on CoderAgentSettings (or CoderLanguageModelConfig
when using the model directly). The global scale is none, minimal, low,
medium, high, xhigh, max. The exported ReasoningEffort type also accepts
other strings for forward compatibility. Model configs returned by
agent.listModels() / client.listModelConfigs(organizationId) expose the
optional reasoning_efforts list of selectable values for each model.
import { CoderAgent } from "@coder/ai-sdk-agent";
const agent = new CoderAgent({
organizationId: "your-org-uuid",
reasoningEffort: "low",
});
await agent.generate({ prompt: "Reply with exactly: pong" });
const chat = await agent.client.getChat(agent.chatId!);
console.log(chat.last_reasoning_effort); // string, null, or absent on the wire
await agent.archive();The setting is independent of the model hint. It is sent as reasoning_effort
on chat creation and every user-message submission, including submissions to
resumed or busy chats that queue the message. When unset, the request key is
omitted entirely. Tool-result submissions and WebSocket reconnects do not post
user messages and do not send this field. There is no per-call reasoning-effort
override; configure it on the agent/model instance. Older Coder servers that do
not recognize reasoning_effort ignore the unknown JSON field, so setting it is
safe but has no effect there.
CoderAgent (implements ai.Agent)
└─ ToolLoopAgent (ai) ← inherits generate()/stream(), loop control
└─ CoderLanguageModel ← implements @ai-sdk/provider LanguageModelV4
└─ CoderChatClient ← REST + WebSocket to /api/v2/chats
└─ Coder Agents ← runs the agent loop SERVER-side
- One
doStreamcall advances the chat until it settles (waiting/completed) or pauses for a client tool (requires_action). The SDK loop and the server‑side loop mesh at the client‑tool boundary, so there's no double loop. - Streaming text is emitted from
message_partdeltas; everymessagesnapshot is then reconciled against a per‑message emitted‑content ledger, so nothing double‑counts: a trailing snapshot after deltas is a no‑op, a fast snapshot‑only turn emits in full, a message that commits while the stream is redialing yields exactly its missing tail, and a revision that appends to an earlier message yields the appended suffix (rewrites that can't be expressed as deltas are safely suppressed).
How to run one agent session across process boundaries — queue jobs, durable‑workflow steps (Vercel Workflow, step functions, Temporal, …), cron ticks — and survive the crashes, stream drops, and timeouts in between. The mechanics this how‑to leans on are specified in Sessions, Timeouts & cancellation, Handling errors, and Observability.
Two facts make the pattern work:
- All chat state lives on the Coder server. Messages, tool activity, the
run itself — none of it is in your process. The only durable thing a
workflow has to carry between steps is
agent.chatId: a string — plus, optionally,agent.lastSeenMessageId: the resume cursor, which saves the resumed turn one serial round‑trip (below). CoderAgentcan't ride afetch‑shim durability layer (it talks REST + WebSocket through its own client), so each turn runs inside a durable step — and the checkpointed chat id is the thread between steps.
Running example: a pipeline whose steps each run as their own job — possibly on another machine, hours apart, retried after failures.
import { CoderAgent, type CoderTransportEvent } from "@coder/ai-sdk-agent";
interface WorkflowCheckpoint {
chatId: string;
/** The resume cursor — pairs with chatId, never persisted without it. */
lastSeenMessageId?: number;
}
// Any durable KV your engine gives you: step state, a job row, a DB table.
declare const checkpoints: {
get(workflowId: string): Promise<WorkflowCheckpoint | undefined>;
set(workflowId: string, checkpoint: WorkflowCheckpoint): Promise<void>;
};
export async function runTurn(workflowId: string, prompt: string): Promise<string> {
const checkpoint = await checkpoints.get(workflowId); // undefined on the first step → the turn creates the chat
const agent = new CoderAgent({
baseUrl: process.env.CODER_URL!,
token: process.env.CODER_SESSION_TOKEN!, // read per step — never checkpoint or log it
organizationId: process.env.CODER_ORG_ID!,
chatId: checkpoint?.chatId,
// The previous step's cursor lets this step skip its pre-prompt history
// probe; absent (first resume, older checkpoints), the turn seeds it from
// the chat's newest message instead — one extra GET, same result.
lastSeenMessageId: checkpoint?.lastSeenMessageId,
requestTimeoutMs: 300_000, // always bound workflow steps — see below
onTransportEvent: observe, // per-step telemetry — see below
});
try {
const { text } = await agent.generate({
prompt,
// Total wall-clock for the step: requestTimeoutMs bounds each segment,
// not a whole multi-segment (client-tool) turn — see "Bound every step".
abortSignal: AbortSignal.timeout(600_000),
});
return text;
} finally {
// The durable resume handle — written even when the turn failed: a first
// step that fails AFTER creating its chat (timeout, stream loss) must
// still persist the id, or the retried step orphans a live chat and its
// partial effects. Exception: after an exhausted stream failure on a
// chat this very call created, the SDK has already discarded the dead
// session (agent.chatId is undefined) and nothing is written — the thrown
// CoderStreamError's chatId still names the stranded chat, and
// agent.archive() retires it via agent.lastKnownChatId — see the
// CoderStreamError notes below for what retrying means then.
if (agent.chatId) {
const next: WorkflowCheckpoint = { chatId: agent.chatId };
// The cursor is optional sugar: persist it when a turn established one
// (a turn that failed before streaming may not have), skip it otherwise.
if (agent.lastSeenMessageId !== undefined) {
next.lastSeenMessageId = agent.lastSeenMessageId;
}
await checkpoints.set(workflowId, next);
}
}
}The shape matters:
- One turn per step,
generate()notstream(). The checkpointed unit is a finished result — and a mid‑stream()failure surfaces on the stream, outsidegenerate()'s error contract, which complicates step retry logic. - Persist the id, not the instance (and never the token — read it from each step's environment).
- Checkpoint in
finally, not only on success, so failed-but-alive chats stay reachable. What remains is the hard-crash window between chat creation and the checkpoint write (below). - No
await usinghere. The chat outlives the step; disposal archives it and archiving ends resumability (below).
// Step 1 — a queue job on machine A:
await runTurn("wf-1042", "Investigate the failing nightly build and propose a fix.");
// Step 2 — hours later, on machine B: same chat, full server-side history.
await runTurn("wf-1042", "Apply the fix you proposed and summarize what changed.");
// Final step — cleanup belongs to the workflow, not to each step (see below).
await archiveWorkflowChat("wf-1042");What resuming does (and doesn't do):
- Construction is free.
new CoderAgent({ …, chatId })performs no I/O — a bad or archived id fails the turn, not the constructor. (The one exception is local validation oflastSeenMessageId: a cursor that isn't a positive integer, or arrives without itschatId, throws immediately.) - The first resumed turn seeds its cursor from the chat's newest message
(one single‑message history probe), so it streams only its own events —
it never re‑absorbs earlier turns' content or usage. Supplying the
checkpointed
lastSeenMessageIdskips that probe: the previous step already knew the cursor, so the resumed prompt goes out without any pre‑prompt round‑trip. - Re‑supply process‑local configuration. Client
toolslive in your process, not on the server, so each step that expects tool calls must pass them again. The workspace binding is the opposite: fixed at creation, carried by the chat, unchangeable on resume. - Sessions stay single‑flight across the whole system, not just within one
process: run steps strictly sequentially per chat. A second job posting to
the same chat queues behind the live run and burns its own
requestTimeoutMswaiting — it attributes nothing of the live run's output to itself (its window opens only when its own queued prompt materializes), and if it times out first, it withdraws its queued prompt rather than leaving it to run unattended. The live run's turn is guarded symmetrically: the server starts the queued prompt's run without ever emitting a terminal settle for the finished one, so the SDK settles the finishing turn the moment the promoted prompt appears on its stream — nothing of the next turn's output or usage leaks into it. That settle reportsfinishReason: "stop"as a documented approximation: the wire doesn't distinguish a run that completed into a promotion from one interrupted bybusy_behavior: "interrupt", so an interrupted turn also settles"stop"(with whatever partial output it committed) rather than surfacing an interruption.
Self‑healed, invisible to the step (the run is not interrupted):
- Transient stream drops. The server keeps generating through the gap; the SDK redials with backoff and replays what it missed — committed messages past the turn's cursor plus the in‑progress message's deltas — deduplicating on receipt. A message that committed while the connection was down arrives as exactly its missing tail. Drops cost latency, never content, and never duplicate content.
- Client‑tool pauses ride one connection. A multi‑segment turn keeps its
stream across
requires_actionpauses, and a drop while your tool executes redials in the background. - A lost tool‑call event. If a turn pauses for client tools but the pause's tool‑call event doesn't arrive within ~2 s, the SDK recovers the pending calls from committed history over REST and the turn continues.
Surfaced to the step once self‑healing is exhausted:
import { CoderApiError, CoderChatError, CoderStreamError } from "@coder/ai-sdk-agent";
try {
await runTurn(workflowId, prompt);
} catch (err) {
if (err instanceof CoderChatError && err.retryable) {
// Timeout or transient turn failure. The run was interrupted (best-effort)
// and the chat survives — but a re-run resubmits the prompt as a new user
// turn: retry only steps designed to tolerate that (below).
} else if (err instanceof CoderStreamError) {
// Redial budget exhausted (~15s without forward progress). On a resumed
// chat this is never auto-retried — the retry decision is yours (below).
} else if (err instanceof CoderApiError) {
// Branch on err.status: back off and retry 408/425/429/5xx; fail the workflow
// on the rest (expired token, archived/deleted chat, …).
}
throw err;
}CoderChatErrorwithretryable: true— the segment'srequestTimeoutMsexpired (kind: "timeout") or the turn failed transiently (kind: "stream_closed", an upstream 5xx).retryableclassifies the failure as transient — it does not make a re‑run free. Unlike the guardedCoderStreamErrorpath below, the session is not discarded: the retried step reloads the checkpointed chat id (thefinallycheckpoint above is what makes this hold even when the first step fails after creating its chat) and resubmits the same prompt as a new user turn, with the aborted attempt's partial output in history and any tool effects that ran before the failure not undone. The same idempotency judgment as below applies — retry steps designed to tolerate re‑submission; reconcile first when they aren't. And don't resubmit immediately: the expiry's server interrupt is asynchronous and best‑effort, so the timed‑out run may still be committing — run the same interrupt‑and‑wait‑for‑terminal‑status recovery as after a crash (below) before the retry, or its trailing output is absorbed into the retried turn. A timed‑out run can also have finished before that interrupt landed — but because the interrupt was already fired, the recovery's result check must treat the attempt as cut short (pincutShortthere) rather than trusting its own 409.CoderStreamError(an AI SDKAPICallError) — the stream could not be re‑established.isRetryable: trueonly when the failed turn had just created its chat and had no external effects a replay would repeat (no workspace, no MCP servers, no fresh inline uploads); the SDK then discards the dead session so a retry —maxRetriesor a re‑run step — starts clean on a fresh chat. The discard happens for every chat the failed turn itself created — including the effectful,isRetryable: falsecase:agent.chatIdisundefinedand thefinallycheckpoint writes nothing — but the stranded chat is not nameless: the error'schatIdfield names it, andinterrupt()/archive()keep targeting it viaagent.lastKnownChatId(Cleanup), so the step's error path can retire the orphan directly — or checkpointerr.chatIdfor the reconciliation sweep (below) when its workspace/MCP effects need reconciling first. A retried step then starts a fresh chat that has not seen the orphan's effects: gate that retry on the same idempotency judgment as any re‑submission. On a resumed chat it is alwaysisRetryable: false: re‑running the step resubmits the same prompt as a new user turn, with the failed attempt's partial output still in history (usually fine — the model sees its own aborted attempt), and any workspace/MCP tool effects that ran before the drop are not undone. That judgment call belongs to your workflow, which is exactly why it isn't automatic.CoderApiError— an HTTP request failed; every non‑2xx wraps in this error, so branch onstatusbefore deciding. Rate limiting and transient server failures (408, 425, 429, 5xx) deserve backoff and a step retry (with the same re‑submission caveat as above). Auth failures and archived/deleted chats (401/403/404, the archived‑chat 400) hit the same wall on every attempt — fail the workflow.
A hard crash (OOM kill, host loss) runs no finally blocks. Reason from the
checkpoint:
-
Crashed with an empty checkpoint — usually nothing exists server‑side and the retried step just creates a fresh chat. But an empty checkpoint is not proof of nothing: a crash or
requestTimeoutMsexpiry while the create request was in flight can leave a chat the server committed but whose id never arrived — unreachable even by the SDK's own interrupt. Treat "no checkpoint" as up to one unacknowledged chat per attempt may exist — not as a clean slate, and automatic step retries can stack several. The reconciliation sweep below must therefore retire every matching orphan (and its workspace/MCP effects, if the deployment auto‑attached any), not stop at the first. -
Crashed after creation, before the checkpoint — the chat is orphaned: its run keeps generating until it settles on its own — or, if the crash hit one of your client tools, never settles: it stays paused in
requires_action, holding its workspace, invisible to the retried step (which has no id to interrupt). Either way the retried step starts a fresh chat, so orphans are found only by the reconciliation sweep from Preventing stuck turns — that sweep is not optional in this pattern, and it needs a wider filter here: the fleet sweep watches for non‑terminal chats stuck past their budget, but an orphaned first turn may settle terminally on its own and then sit as a live, unarchived chat forever. Sweep every chat no checkpoint accounts for — regardless of status — and interrupt/archive it. (To shrink the window, checkpoint eagerly from the firstws:dialtransport event — it carries the chat id as soon as the chat exists — but then the checkpoint's lifecycle is yours too: the retried step resumes a chat holding a dead half‑turn, and whenever aCoderStreamErrorkilled a chat this step created — retryable or not: the SDK discards every such session, but only in memory — you must clear the eagerly checkpointed id yourself before any retry, or "starts fresh after a discard" silently turns into resuming the dead chat.) -
Crashed after the checkpoint — the chat is resumable, but don't just resubmit. The crashed attempt's run may still be live server‑side, and a resubmission then queues behind it: nothing the old run commits is attributed to the resumed turn (its window opens only at its own queued prompt's materialization), but the step burns its
requestTimeoutMswaiting for the old run to finish first — and a timeout withdraws the queued prompt again. A crash inside one of your client tools is worse: the chat is paused inrequires_action, where new messages wait forever — and if the tool's external effect committed before the crash, that pause is the only pending record of it, so reconcile the tool ledger before any interrupt (Make client tools crash-safe). Both cases have one remedy — stop what the crash left behind and wait until the chat actually settles before deciding: a client‑tool pause is reconciled, never interrupted away; anything else is interrupted:interrupt()resolves on acknowledgment while the run keeps winding down (and committing) for a few more seconds, so poll the chat's status to a terminal one first. On a chat with no live run the interrupt rejects with a 409CoderApiError; that's the good case — ignore it:// Both shared with "Make client tools crash-safe" (below): the ledger // reconciliation resolves to its `unstarted` — true only when it fell back // to interrupting — and journals each action write-ahead, so a recovery // pass that dies mid-recovery is visible — and classifiable — to the next. declare function reconcileClientTools(): Promise<boolean>; // Journal entries are per-ATTEMPT, so the chat id alone cannot scope them: // a verdict from attempt N must not classify attempt N+1 (double crash), // and a later step's recovery on the same chat must not inherit this // one's. Every entry pins the step marker and an attempt count at write // time; a consult applies an entry only while both still match, so each // resubmission retires every earlier verdict by itself — there is no // clear step to forget. type JournalEntry = { // "submitting" is written ahead of every send on a checkpointed chat; // "resumed" / "cut-short" are the ledger reconcile's verdict (both in // the next sections). state: "submitting" | "resumed" | "cut-short"; marker: string; // the step-scoped marker ("Recover the result…", below) attempt: number; // markerAttempts() at write time at: number; // write time — bounds the in-flight-send wait below }; declare const journal: { get(chatId: string): Promise<JournalEntry | undefined>; set(chatId: string, entry: JournalEntry): Promise<void>; }; // This step's attempts committed so far: user messages in the converted // transcript carrying the marker. Steps run sequentially, so they sit // contiguous at the tail of history — page newest-first (as the recovery // scan below already does) and stop at the first user message without it. declare function markerAttempts(): Promise<number>; // Bounded: a stalled deployment must fail the step, not hang it. const deadline = AbortSignal.timeout(15_000); // Whether an interrupt stopped a live run drives the resubmit decision // below: a 409 means there was nothing to stop. let cutShort = true; let { status } = await agent.client.getChat(agent.chatId!, deadline); // Recompute what the entry pinned at write time; a mismatch on either // means it classifies an earlier attempt (or another step's) — dead, skip. const entry = await journal.get(agent.chatId!); const attempts = await markerAttempts(); const journaled = entry?.marker === marker && entry.attempt === attempts ? entry : undefined; if (status === "requires_action") { // The crash hit a client tool mid-execution: this pause is the only // pending record of its committed effects. Never interrupt it away — // reconcile the ledger instead (below): it submits recorded results // (reviving the run — nothing was cut short) or interrupts itself only // when nothing committed. cutShort = await reconcileClientTools(); } else if (journaled && journaled.state !== "submitting") { // A previous recovery pass acted on this attempt, then died before the // step's result was recorded. Its journaled state is the verdict: // "resumed" — it revived the run by submitting recorded results; // interrupting now would cut down a run doing the step's work, and the // resubmitted prompt would mint NEW tool-call ids, sidestepping the // idempotency keys. Let it finish; nothing was cut short. // "cut-short" — it went on to interrupt (an unstarted sibling in the // batch); the attempt is truncated, so the check below must resubmit. cutShort = journaled.state === "cut-short"; if (cutShort) { // The journal records write-ahead INTENT, not a landed interrupt: the // pass may have died in between, leaving the revived run working. // Re-drive the interrupt to make the intent true — a 409 means the // chat already settled; the cut-short verdict stands either way. await agent.interrupt({ signal: deadline }).catch((err) => { if (!(err instanceof CoderApiError && err.status === 409)) throw err; }); } } else { await agent.interrupt({ signal: deadline }).catch((err) => { // A 409 proves nothing is stoppable NOW — that means "never cut short" // only if the probe saw no interrupt already in flight. A probed // "interrupting" is an earlier interrupt (a timeout's best-effort one, // a sweeper) already truncating the attempt; this 409 just says it // landed. Keep cutShort = true then. if (err instanceof CoderApiError && err.status === 409) cutShort = status === "interrupting"; else throw err; }); } // One thing can still be in flight here: the crashed attempt's own SEND. A // matching "submitting" entry — its attempt count has not grown — means the // process died with `createChatMessage` possibly en route, and the server // can commit it AFTER every read above (the 409 only said nothing was // running THEN). Sleep out what remains of the entry's commit window — // requestTimeoutMs past the write is the conservative horizon, and a // restart has usually consumed it already — so a late-landing turn starts // before the settle poll below and the next section's negative history // read is past the window. Then recount: a send that never landed OVER AN // EXISTING attempt pins cutShort — the step only ever plans a resubmission // over a cut-short, errored, or unsubmitted attempt, so the write-ahead // that superseded a "cut-short" verdict still carries its consequence, and // the scan below cannot mistake the old attempt's truncated tail for a // finished result. if (journaled?.state === "submitting") { await new Promise((r) => setTimeout(r, Math.max(0, journaled.at + 300_000 - Date.now()))); if ((await markerAttempts()) === journaled.attempt && journaled.attempt > 0) cutShort = true; } // The 15 s interrupt bound must not cap what follows: a reconciled pause // revives a full model/tool continuation. Budget the settle poll — and the // history reads after it — on the turn scale, like the step itself. const settle = AbortSignal.timeout(600_000); do { ({ status } = await agent.client.getChat(agent.chatId!, settle)); if (status === "waiting" || status === "completed" || status === "error") break; // With no accepted interrupt racing to clear it, requires_action is a // STABLE pause: a run resumed by submitted tool results called a NEW // client tool. Exit and reconcile the ledger again (below) instead of // polling out the deadline. if (status === "requires_action" && !cutShort) break; await new Promise((r) => setTimeout(r, 1_000)); // acknowledged ≠ settled — wait it out } while (true);
Settled is necessary, not sufficient: a terminal status doesn't say whether the crashed attempt already finished the step's work — check history before resubmitting (Recover the result before resubmitting).
Interrupting un‑strands a requires_action pause — but if the crashed
process died between a non‑idempotent client tool committing its external
effect (a payment, a deploy, an email) and that result reaching the server,
the pause is the only pending record of the execution. Interrupt it away
and the resubmitted prompt is free to call the tool again — and duplicate the
effect. Close the window with a tool‑invocation ledger in the same durable
store as the chatId checkpoint, keyed by chat id + toolCallId — the
server assigns the call id and commits it to history, so both sides of the
reconciliation survive the crash. The chat id must be part of the key (and
of the idempotency key handed to the external system): call ids are only
unique within a chat, and a store shared across workflows must never let
one chat's done entry answer — or deduplicate — another chat's call. In
multi‑tenant stores, fold the tenant in too:
import { tool } from "ai";
import { z } from "zod";
import { CoderAgent } from "@coder/ai-sdk-agent";
type LedgerEntry = { state: "committing" } | { state: "done"; output: string };
declare const ledger: {
get(key: string): Promise<LedgerEntry | undefined>;
set(key: string, entry: LedgerEntry): Promise<void>;
};
declare const payments: {
charge(amountCents: number, opts: { idempotencyKey: string }): Promise<{ receiptId: string }>;
};
const ledgerKey = (chatId: string, toolCallId: string) => `${chatId}/${toolCallId}`;
const ChargeArgs = z.object({ amountCents: z.number().int() });
// A factory taking a getter, not an id: the chat may not exist until the
// turn creates it, so the id must be read lazily — it is set before any
// tool runs.
const chargeCard = (chatId: () => string) =>
tool({
description: "Charge the customer's card.",
inputSchema: ChargeArgs, // shared with recovery's re-drive dispatch below
execute: async ({ amountCents }, { toolCallId }) => {
const key = ledgerKey(chatId(), toolCallId);
// Write-ahead, BEFORE the effect: a crash between the two writes leaves
// "committing" (ambiguous, but resolvable below); no entry at all proves
// the effect never started.
await ledger.set(key, { state: "committing" });
// Hand the external system the SAME key as its idempotency key, so
// re-driving this exact call can never double-charge.
const { receiptId } = await payments.charge(amountCents, { idempotencyKey: key });
await ledger.set(key, { state: "done", output: `charged: receipt ${receiptId}` });
return `charged: receipt ${receiptId}`;
},
});
// Wire-up: a direct self-reference in the constructor call is circular for
// the type checker — defer it through a box assigned right after.
let self: { chatId?: string } | undefined;
const agent = new CoderAgent({
/* …base options as in runTurn… */
tools: { charge_card: chargeCard(() => self!.chatId!) },
});
self = agent;On crash recovery, when the checkpointed chat is paused in requires_action,
reconcile before any interrupt. The pending calls are in committed history —
the same derivation the SDK itself uses to recover a lost action_required
event — and the ledger holds the verdict on each:
// The step's configured ToolSet names: history does not record which calls
// were client tools (see [Rehydrating chat history]), so — like the SDK's
// own derivation — restrict to the names this step registers; a server/MCP
// call must never reach the ledger as "unstarted". (The recovery scan's
// final-text cut reuses this set — next section.)
const clientTools = new Set(["charge_card"]);
// Pending = the last assistant message's client (non-provider-executed,
// ToolSet-named) tool calls, minus ids answered by a later message's tool
// result.
const { messages } = await agent.client.getMessages(agent.chatId!, { limit: 200 }, deadline);
const ordered = [...messages].sort((a, b) => a.id - b.id);
const lastAssistant = ordered.findLast((m) => m.role === "assistant");
const handled = new Set(
ordered
.filter((m) => m.id > (lastAssistant?.id ?? 0))
.flatMap((m) => m.content ?? [])
.filter((p) => p.type === "tool-result")
.map((p) => p.tool_call_id),
);
const pending = (lastAssistant?.content ?? []).filter(
(p) =>
p.type === "tool-call" &&
!p.provider_executed &&
clientTools.has(p.tool_name!) &&
!handled.has(p.tool_call_id),
);
const results: { tool_call_id: string; output: unknown }[] = [];
let unstarted = false;
for (const call of pending) {
const key = ledgerKey(agent.chatId!, call.tool_call_id!);
const entry = await ledger.get(key);
if (entry?.state === "done") {
// Effect committed, result stranded: submit the record — the pause is
// answered and nothing re-executes.
results.push({ tool_call_id: call.tool_call_id!, output: entry.output });
} else if (entry) {
// "committing" is ambiguous — the crash hit between the write-ahead and
// "done". Safe to re-drive: the SAME idempotency key makes a committed
// effect replay as a no-op, and the call's args are committed in history.
// Dispatch by tool name — every ledgered tool needs its own re-drive
// path, its args validated exactly as the live loop would have.
if (call.tool_name !== "charge_card")
throw new Error(`no re-drive path for tool ${call.tool_name}`);
const { amountCents } = ChargeArgs.parse(call.args);
const { receiptId } = await payments.charge(amountCents, { idempotencyKey: key });
const output = `charged: receipt ${receiptId}`;
await ledger.set(key, { state: "done", output });
results.push({ tool_call_id: call.tool_call_id!, output });
} else {
unstarted = true; // the tool never ran — interrupting loses nothing
}
}
if (results.length > 0 || unstarted) {
// ONE write-ahead with the batch's FINAL verdict, before any network
// call — a mixed batch journals "cut-short" before even its submission,
// so no crash point can leave a stale "resumed" masking the pending
// interrupt. Every crash then either re-reconciles the stable pause or
// re-drives the journaled intent. Scoped to THIS attempt: a resubmission
// grows the marker count and retires the verdict on its own.
await journal.set(agent.chatId!, {
state: unstarted ? "cut-short" : "resumed",
marker,
attempt: await markerAttempts(),
at: Date.now(),
});
}
if (results.length > 0) await agent.client.submitToolResults(agent.chatId!, { results }, deadline);
if (unstarted) await agent.client.interruptChat(agent.chatId!, deadline);
// As `reconcileClientTools()` in the settle-wait snippet above: resolve to
// `unstarted` — cutShort is true only when this fell back to interrupting.- Only interrupt when the ledger shows no committed effect. Submitted results are committed to chat history, so even when a mixed batch (one call done, a sibling never started) still ends in an interrupt, the effect's recorded outcome survives for the resubmitted turn to see.
- Submitting answers the pause; it does not revive your tool loop. Once
every pending call is answered, the turn resumes server‑side with no
process streaming it. That is why the settle‑wait's reconcile branch never
interrupts a pause — nothing stranded is left to stop, and an interrupt
now would cut down the very run the submission revived — and why the
resolved
unstartedleavescutShort = falseafter a submission: nothing was cut short, and the next section's!cutShortcheck must classify the resumed run's finish as a completed attempt, not resubmit it. Give the poll a turn‑scale deadline rather than the 15 s interrupt bound; it exits onrequires_action(stable here, with no accepted interrupt clearing it), which means the resumed run called a new tool — reconcile again; a call with no ledger entry is safe to interrupt. - The finished run's output lands only in history — recover it like any completed‑but‑unrecorded attempt (next section) instead of resubmitting.
The settle‑wait resolves overlap with a still‑live run, but not the last crash window: the server finished the turn, and the process died before the step's return value was recorded. History now holds a perfectly good result; a retried step that blindly resubmits runs the whole turn again — duplicated output, repeated workspace/MCP/tool effects. This is the at‑least‑once boundary every durable engine has around external effects, and the chat itself is the record that narrows it to one honest window (below). Make attempts matchable first — the server stores the prompt verbatim, so embed a step‑scoped marker — and journal every send before it leaves the process:
const marker = `[${workflowId}/step-2]`; // stable across this step's retries
// Write-ahead, BEFORE the send — first attempt and recovery resubmit alike.
// A crash before this write proves no send ever started; a crash after it
// leaves a dated entry whose commit window the settle-wait sleeps out. On a
// resubmission this write supersedes the "cut-short" verdict it acts on —
// safely: an unlanded send over an existing attempt makes the settle-wait
// pin cutShort right back. A first turn has no chat id to journal under: its
// send-crash window belongs to the empty-checkpoint sweep
// ("Recover after a crash").
if (agent.chatId) {
await journal.set(agent.chatId, {
state: "submitting",
marker,
attempt: await markerAttempts(),
at: Date.now(),
});
}
const { text } = await agent.generate({ prompt: `${marker} ${prompt}` });Then, after the settle‑wait, three states are distinguishable — and only one of them resubmits:
import { chatMessagesToUIMessages } from "@coder/ai-sdk-agent";
// A long turn can commit more than a page of messages after the prompt —
// page newest-first (the endpoint's default order) until the turn's user
// message is on hand; "never submitted" must never be concluded from a
// truncated read, or this recovery resubmits exactly the turn it was meant
// to deduplicate.
const fetched = [];
let page = await agent.client.getMessages(agent.chatId!, { limit: 200 }, settle);
fetched.push(...page.messages);
while (!fetched.some((m) => m.role === "user") && page.has_more) {
const oldestId = Math.min(...fetched.map((m) => m.id));
page = await agent.client.getMessages(agent.chatId!, { before_id: oldestId, limit: 200 }, settle);
fetched.push(...page.messages);
}
const transcript = chatMessagesToUIMessages(fetched); // chronological
const lastUser = transcript.findLast((m) => m.role === "user");
const submitted = lastUser?.parts.some((p) => p.type === "text" && p.text.includes(marker));
if (submitted && !cutShort && (status === "waiting" || status === "completed")) {
// The attempt finished; only the recording was lost. Recover what
// `generate().text` would have returned — the FINAL step's text. Only a
// CLIENT tool ends an AI SDK step, and narration lands on both sides of a
// server-side call, so cut after the last part named in `clientTools`
// (shared with the reconcile above) and join the text that follows.
const parts = transcript
.slice(transcript.indexOf(lastUser!) + 1)
.filter((m) => m.role === "assistant")
.flatMap((m) => m.parts);
return parts
.slice(parts.findLastIndex((p) => p.type === "dynamic-tool" && clientTools.has(p.toolName)) + 1)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("");
}
// Resubmit only what never finished: the prompt never reached the server
// (!submitted — sound only behind the write-ahead; see below), the turn
// failed (status "error" — normal retry judgment applies), or the interrupt
// cut a live run short (cutShort — the model sees its own aborted attempt in
// history and continues from it). One exit is neither: a requires_action
// settle goes back to ledger reconciliation (previous section) — never to
// recovery or resubmission.-
cutShortis the completed/interrupted discriminator — but a 409 only proves nothing is running now. An interrupted attempt leaves the same marker and a truncated answer in history, so history alone can't tell "finished" from "stopped"; what can is whether any interrupt stopped the run. The inference "409 ⇒ the attempt finished" is therefore sound only when recovery's interrupt is the first one that could have: true after a hard crash, false after arequestTimeoutMsexpiry — the expiry already fired the SDK's own best‑effort interrupt, which may have stopped the run before recovery ever looked. Carry the reason recovery ran: when it follows a known timeout or abort — or a matching journaled"cut-short", or an unlanded"submitting"over an existing attempt (the settle‑wait already pins both) — pincutShorttotrueand let only the hard‑crash path trust the 409. -
!submittedraces the crashed attempt's own send. A crash kills the process, not necessarily its in‑flightcreateChatMessage— the server can commit that request after an interrupt 409'd and a history read saw no marker, and a resubmit on that evidence queues the turn twice, duplicating its workspace/MCP/tool effects. The write‑ahead is what makes the negative read honest: no matching"submitting"entry proves no send ever started, and a matching one held recovery at the settle‑wait until the entry's commit horizon passed. That narrows the window; only server‑enforced submission idempotency could close it, andPOST /chats/{id}/messagesoffers none today. A commit that outlasts even the horizon still lands under the same marker, so the duplicate is at least visible — in history, and to the next consult's attempt count. -
The cut lands at the last configured client‑tool boundary. History does not mark which calls were client tools (Rehydrating chat history), so the cut classifies by name against the reconcile's
clientToolsset — narration before a trailing server‑side call survives, matching whatgenerate().textaggregates. What name‑matching cannot see: a call recorded under a name the step no longer configures (a tool renamed between deploys) reads as server‑side and leaks earlier‑step narration into the result, and a server tool sharing a configured name still cuts final‑step narration away. -
Steps with structured results recover the call's input, not the text. A structured output step's answer is the
structured_outputcall's typed input, which rehydrates as adynamic-toolpart on the recovered turn (Rehydrating chat history) — andstructured_outputis one of the step's configured client tools, so the text join above cuts at it and returns only the ack prose that follows. Recover the filed call instead — this scan replaces the text join inside the recovery branch above, reusing itsparts— validated client‑side exactly like the live path (rule 2 there — the schema is the real gate):// Scan backward to the last call that VALIDATES, as in the live path — a // schema-invalid re-file must not shadow a valid answer. const filed = parts .filter((p) => p.type === "dynamic-tool") .filter((p) => p.toolName === "structured_output"); let answer: z.infer<typeof Answer> | undefined; for (const call of filed.reverse()) { const parsed = Answer.safeParse(call.input); if (parsed.success) { answer = parsed.data; break; } } // recovered ⇔ answer !== undefined
Transport events classify how each segment ended without parsing errors or stream frames — emit them as step metrics/traces:
declare const metrics: {
ok(segment: number, status: string, ms: number): void;
fail(segment: number, error: string, detail: string): void;
teardown(segment: number): void;
};
function observe(ev: CoderTransportEvent): void {
if (ev.type === "ws:redial") {
// Early warning: the stream dropped and is reconnecting. The server keeps
// generating — nothing is lost yet; the turn fails if this hits the cap.
console.warn(
`redial ${ev.consecutiveFailures}/${ev.maxConsecutiveFailures}, next in ${ev.backoffMs}ms`,
);
} else if (ev.type === "segment:settle") {
if (ev.finishReason) metrics.ok(ev.segment, ev.status!, ev.durationMs);
else if (ev.error) metrics.fail(ev.segment, ev.error.name, ev.error.message);
else metrics.teardown(ev.segment);
}
}segment:settle fires exactly once per segment:start and separates the
three endings a workflow cares about:
- Clean settle —
status+finishReasonpresent.finishReason: "tool-calls"(statusrequires_action) is a healthy mid‑turn pause for your client tools; terminal statuses end the turn. - Failure —
error: { name, message }present (plusstatuswhen the server run itself still settled terminally): server‑side turn errors, transport failure after redials were exhausted,requestTimeoutMsexpiry (error.name: "CoderChatError"), and caller aborts — which settle as failures carrying the abort reason's name, never as teardowns:"TimeoutError"for anAbortSignal.timeout(…)deadline (the running example),"AbortError"for an explicit abort. - Teardown — neither: the stream consumer itself cancelled the turn
(
stream()'sReadableStream.cancel()with no abort of its own) — rare ingenerate()‑shaped steps.
The hook is observability‑only — handler exceptions are swallowed and can't disturb the turn, and an unset hook costs nothing. For monitoring across steps (chats stuck before a watcher started, fleet‑level sweeps), see Preventing stuck turns.
requestTimeoutMs arms one timer per segment covering everything the
segment does: the REST phase (chat creation, message/tool‑result submission,
uploads), the stream — and, the part that matters here, any redial backoff
inside the segment. A segment that spends 12 s reconnecting has 12 s less
budget; redials never reset the clock, so a flaky network can't stretch a step
past its bound. On expiry the call rejects with CoderChatError
(kind: "timeout", retryable: true) and the server run is interrupted
best‑effort.
Two workflow‑specific notes:
- A turn that times out (or crashes) before its chat id arrived leaves nothing to resume — the checkpoint stays empty and the retried step starts fresh — but possibly an unacknowledged chat to sweep (see Recover after a crash).
requestTimeoutMsbounds each segment; a turn driving client tools runs several. Cap a step's total wall‑clock withabortSignal: AbortSignal.timeout(…)— Timeouts & cancellation.
An archived chat cannot be resumed: the server rejects new messages on it
(400, Cannot send messages to an archived chat). The per‑request habits from
Cleanup — await using, archive() in a finally — would
destroy the session from inside an intermediate step, so here the workflow
owns the chat's end of life: archive once, in the final step, and in the
workflow's failure/compensation handler so abandoned runs don't accumulate
live chats.
export async function archiveWorkflowChat(workflowId: string): Promise<void> {
const checkpoint = await checkpoints.get(workflowId);
if (!checkpoint) return; // the workflow died before its first turn created a chat
const agent = new CoderAgent({
baseUrl: process.env.CODER_URL!,
token: process.env.CODER_SESSION_TOKEN!,
organizationId: process.env.CODER_ORG_ID!,
chatId: checkpoint.chatId,
});
// A crashed step may have left a run live — stop it first, bounded. On an
// already-settled chat the interrupt rejects with a 409; ignore it.
await agent.interrupt({ signal: AbortSignal.timeout(15_000) }).catch(() => {});
await agent.archive(); // retries 409s while the interrupted run winds down (~15s)
}- One turn per durable step;
generate(), notstream(). - Persist
agent.chatId(a string) — never the instance, never the token. - Bound every step:
requestTimeoutMsper segment, an abort deadline for total wall‑clock. - Let redials self‑heal; own every retry decision — a re‑run step resubmits its prompt as a new user turn.
- Before resubmitting after a crash: reconcile the tool ledger before any interrupt, recover a finished attempt's result from history, and trust "never submitted" only past the journaled send's commit window.
- Keep concurrent steps' fan‑out within workspace quota — Workspaces & quota.
- Steps that don't need server‑side tools (plan / extract / synthesize) are
cheaper and natively structured through
@coder/ai-sdk-provider+generateObject— no chat, no workspace, no cleanup. - Archive in the final step / failure handler — never per step.
pnpm test # unit tests (hermetic, mocked client)
pnpm typecheck
pnpm lint # lint with oxlint
pnpm format # format with oxfmt (or `pnpm format:check` to verify only)
pnpm check # format check + lint + typecheck (CI gate)
pnpm buildEnd‑to‑end tests run against a live Coder deployment and are opt‑in via env:
CODER_URL=https://dogfood.cdr.dev \
CODER_SESSION_TOKEN=$(coder tokens create --name e2e) \
pnpm test:e2eThe e2e suite creates new chats only (no workspaces) and archives them afterward.
- Older deployments use a temporary experimental-route fallback; it is not a guarantee that every chat feature is available (see API compatibility).
- Designed for Node (WebSocket via
ws); a browser build can inject awebSocketFactory. - A v7
@ai-sdk/harnessadapter (the conceptually exact fit) is a future direction once that experimental API stabilizes.
Apache-2.0