@superdurable/dex-agents-claude-agent-sdk runs an agent built with the
Claude Agent SDK for TypeScript
(@anthropic-ai/claude-agent-sdk) as a durable Dex agent session. It implements the
@superdurable/dex-agents runner contract and registers the
shared agent session Flow, so a session keeps its conversation, approvals, and tool results across
Worker restarts and waits for approval for as long as it takes.
The Claude Agent SDK runs its agent loop in a Claude Code CLI subprocess that the adapter cannot
enter, so it is a whole-turn harness in the session's Step mode. One RunModel Step runs one turn
segment: a CLI process that may make several model calls and inline tool calls until it defers a
tool call or answers. The session stores the CLI's transcript entries in Dex state, and the next
segment, turn, or attempt resumes the Claude session from them on any Worker. Custom tools that write
run in their own Dex Steps; tools that run inside the CLI wait for approval but cannot be recorded.
See the Claude Agent SDK row of the
agent framework adapters plan. Its stored
formats and its behavior match the Python adapter (agents/python/claude-agent-sdk), with two
exceptions: a tool source call that asks the user a question records failed, and an approved tool
that runs inside the CLI resumes with its recorded input. See "Tool sources and connector tools" and
"Limits".
Install the adapter with its peer dependencies, the session core and the Dex
SDK, and zod 4:
npm i @superdurable/dex-agents-claude-agent-sdk @superdurable/dex-agents @superdurable/dex zodThe adapter depends on @anthropic-ai/claude-agent-sdk ^0.3.316 and on
@modelcontextprotocol/sdk ^1.29.0, the range the Claude Agent SDK asks
for, which serves the custom tool server. package-lock.json locks 0.3.316, which bundles Claude Code CLI 2.1.316 as an
optional platform package; the adapter needs it unless you set
pathToClaudeCodeExecutable. @superdurable/dex and @superdurable/dex-agents are peer dependencies.
The application installs exactly one copy of each, because the Dex Registry
matches Flow classes and RPC registrations by identity, and the session
recognizes ToolExecutionError only from its own copy of the core. zod 4 is a peer
dependency, the same one tool() schemas use. Requirements: Node
^22.0.0 || ^24.0.0 and @superdurable/dex 1.45.0. The Worker needs Claude
Code credentials, such as ANTHROPIC_API_KEY, exactly as a plain Claude Agent
SDK program does. Per Anthropic's terms, products built on the Claude Agent SDK
authenticate with an API key (ANTHROPIC_API_KEY); see the
Agent SDK overview. Connector tools also need @superdurable/connectors-sdk
0.9.0, the optional peer of @superdurable/dex-agents/connectors, and each
Connector package the agent uses.
Every segment after the first resumes the Claude session from a temporary config directory that the
Claude Agent SDK prepares. A login that Claude Code stored, such as one in the macOS Keychain,
keeps working across turns. Claude Code finds that login by CLAUDE_SECURESTORAGE_CONFIG_DIR, so the
adapter sets it in the CLI environment to the effective CLAUDE_CONFIG_DIR, or to "" when none is
set. A resumed segment then uses the login of the first one. The CLI environment is the options'
env, or a copy of process.env without one. To choose another login, set
CLAUDE_SECURESTORAGE_CONFIG_DIR in that environment; the adapter keeps your value.
Develop the package inside this repository with ./ci.sh. There
@superdurable/dex-agents is a file:../core devDependency, and the
package's .npmrc sets install-links=true so npm copies the built core
instead of symlinking it; a symlinked core would load a second
@superdurable/dex. Consumers of the published package need neither:
cd agents/typescript/claude-agent-sdk
DEXCLI=/path/to/dexcli ./ci.shimport { tool } from "@anthropic-ai/claude-agent-sdk";
import { Client, Registry, Worker, openBlobCache } from "@superdurable/dex";
import {
createClaudeAgentSessionFlow,
currentDeferredToolExecution,
} from "@superdurable/dex-agents-claude-agent-sdk";
import { startAgentSession } from "@superdurable/dex-agents";
import { z } from "zod";
const lookupOrder = tool(
"lookup_order",
"Look up an order",
{ orderId: z.string() },
async (args) => ({ content: [{ type: "text", text: await orders.describe(args.orderId) }] }),
{ annotations: { readOnlyHint: true } },
);
const createRefund = tool("create_refund", "Refund an order", { orderId: z.string() }, async (args) => {
const execution = currentDeferredToolExecution();
const refund = await payments.refund(args.orderId, { idempotencyKey: execution?.idempotencyKey });
return { content: [{ type: "text", text: `refunded ${refund.id}` }] };
});
const flow = createClaudeAgentSessionFlow(
{
options: (context) => ({
model: "claude-sonnet-5",
systemPrompt: "You resolve order problems.",
tools: ["Read", "Grep", "Bash"],
cwd: `/srv/agents/${context.flowId}`,
settingSources: [],
}),
customTools: [lookupOrder, createRefund],
toolPolicies: {
create_refund: { effect: "non_idempotent_write" },
Read: { effect: "read" },
Grep: { effect: "read" },
},
},
"SupportAgent",
{ interruptDeadlineMs: 24 * 60 * 60 * 1000 },
);
const registry = new Registry([flow]);
const blobCache = openBlobCache({ directory: ".dex-cache", maxBytes: 64 * 1024 * 1024 });
const worker = new Worker(registry, blobCache, { serverAddress: "127.0.0.1:8801" });
await worker.start();
await worker.waitPullRegistered({ timeoutMs: 30_000 });
const client = new Client(registry, blobCache, { serverAddress: "127.0.0.1:8801" });
await startAgentSession(client, flow, "session-42", { initialMessage: "Refund order 7 if it arrived broken" });
const snapshot = await client.invokeRPC(flow.getAgentSnapshot, "session-42");
for (const interrupt of snapshot.pendingInterrupts) {
await client.invokeRPC(flow.resolveInterrupt, "session-42", {
interruptId: interrupt.interruptId,
approved: true,
rejectionMessage: null,
});
}
await client.invokeRPC(flow.sendUserMessage, "session-42", { text: "Thanks" });In this agent lookup_order, Read, and Grep run inside the turn. create_refund waits in
AwaitToolApproval for resolve_interrupt and then runs once in CallSingleAttemptTool. Bash has no policy, so the default effect,
unclassified, makes every call wait for approval; after approval it runs inside the resumed turn.
The session's RPCs, persistence, events, and Flow shape are the core's; see the
core README.
| Export | Purpose |
|---|---|
createClaudeAgentSessionFlow(agent, flowType, sessionOptions?) |
Builds the session Flow from a ClaudeAgentDefinition { options, customTools?, toolPolicies?, customToolServerName?, toolSources?, toolSourceListingTimeoutMs?, claudeCodeToolPermission?, mediaStore? } and the core's session options without toolPolicies |
ClaudeAgentRunner |
The AgentFrameworkRunner; new ClaudeAgentRunner(options, { customTools, customToolServerName, toolSources, toolSourceListingTimeoutMs, claudeCodeToolPermission, mediaStore }) for createAgentSessionFlow. Its toolCallStepOptions gives a source call its source's tool Step options |
ClaudeCodeToolPermission, ClaudeCodeToolDenial |
A per-call decision for tools that run inside the CLI; see "Per-call decisions for Claude Code tools" |
ClaudeAgentQueryOptions |
The SDK Options an application may set |
ClaudeAgentOptionsFactory, ClaudeAgentOptionsContext |
Options per segment from { flowId, turnId, attempt } |
currentDeferredToolExecution() |
Inside a custom tool handler: the DeferredToolExecution of the tool Step, or undefined inline |
approvedCliToolOutput |
Record output of an approved tool that runs inside the CLI |
defaultCustomToolServerName |
"tools" |
userPromptItemKind, claudeTranscriptItemKind, ClaudeTranscriptEntry |
Stored frameworkItem formats |
ClaudeCustomTool |
The type of a tool() definition |
options is either SDK Options or a factory that returns them for every RunModel attempt. Keep a
factory deterministic for a Flow ID: a resumed segment must see the same working directory, tools,
and model as the attempt that deferred the call.
The adapter sets these options for every segment:
| Option | Value |
|---|---|
mcpServers[customToolServerName] |
An in-process SDK MCP server that the adapter builds with @modelcontextprotocol/sdk: the custom tools, then the tool sources' tools |
extraArgs["mcp-config"] |
Declares that server to the CLI at startup, {"type": "sdk", "name": ...}, as the Python SDK does; see "What the real CLI does" |
extraArgs["await-initialize"] |
The CLI waits for the SDK's initialize request before it resumes a deferred call; a defense, see "What the real CLI does" |
hooks.PreToolUse |
Your matchers, each callback skipped for a resumed call that has a final record, then one more matcher for every tool: the adapter's policy decision |
canUseTool |
Denies a deferrable call that Claude Code did not defer, and answers a resumed call from its record if the CLI asks |
sessionStore |
Serves the committed transcript and collects the segment's entries |
resume |
The stored Claude session ID, from the second segment of a session on |
includePartialMessages |
true, for live text |
abortController |
Aborted with the attempt's cancellation signal |
A turn attempt fails with a TypeError when the options set an option the adapter owns
(abortController, canUseTool, continue, enableFileCheckpointing, forkSession,
permissionPromptToolName, resume, resumeDropsTurn, resumeSessionAt, sessionId,
sessionStore, or extraArgs for mcp-config, resume, session-id, or await-initialize); set
permissionMode to "bypassPermissions", "acceptEdits", or "auto"; reuse the custom tool server
name in mcpServers; or list a tool in allowedTools whose calls must defer, such as any source tool. Those settings would
let a tool run without its approval or Step. permissionMode unset (the CLI reports default),
"default", and "dontAsk" all work.
Pass settingSources: [] unless you need settings files. Allow rules in ~/.claude/settings.json
or project settings can approve a tool before the adapter is asked, and the adapter cannot see them.
ToolPolicy and defaultToolEffect work as in the core. Policies use a
custom tool's own name (create_refund, not mcp__tools__create_refund), a built-in name (Bash),
or mcp__<server>__<tool> for a tool of an external MCP server. Without a policy, a custom tool's MCP
annotations classify it: readOnlyHint is read, and otherwise idempotentHint is
idempotent_write, whatever destructiveHint says. A custom tool whose name is a Claude Code tool name, such as Bash, Write, or
Task, or starts with mcp__, is rejected, because policies and records name custom tools bare.
| Effect | Custom tool | Tool inside the CLI | Default approval |
|---|---|---|---|
read |
Runs inside RunModel; reruns with the segment |
Runs inside RunModel; reruns with the segment |
No |
idempotent_write |
CallTool, up to 3 attempts, key <flowId>/<turnId>/<toolCallId>; then outcome_unknown |
Runs inside RunModel, at least once |
No |
non_idempotent_write |
CallSingleAttemptTool, one attempt; then outcome_unknown |
Waits in AwaitToolApproval, then runs inside the resumed RunModel, at least once |
Yes |
unclassified |
Same as non_idempotent_write |
Same as non_idempotent_write |
Yes |
A policy with requiresApproval adds AwaitToolApproval before the call; an approved custom read
call then runs in CallTool. Custom read calls without approval run inside the CLI segment, unlike
the core's Step mode, because every deferral restarts the CLI: one more process, one more transcript
load, and one more model call. Reads are safe to repeat when RunModel retries. A tool inside the CLI
with an approval-free write policy also runs inside the segment, because deferring it would not make
it durable.
A custom tool handler runs with arguments parsed by its input schema; a mismatch records failed
with the validation message and the handler never runs. In a tool Step its extra argument carries
only signal, requestId, and _meta, and currentDeferredToolExecution() returns the attempt and
the idempotency key. A result with isError: true records failed, and the model sees its text. A
thrown error fails the Step attempt: an idempotent_write call retries, and once its attempts run
out, or at once for a single-attempt call, the session records outcome_unknown.
An inline read call runs inside RunModel, not in a tool Step. An error thrown by its handler does
not fail RunModel: the custom tool server turns it into an MCP error result, and the model reads its
text. An inline read tool therefore cannot ask for a RunModel retry. Retry transient failures
inside the handler before it throws.
A policy names a tool, so every call of Bash gets the same approval. claudeCodeToolPermission
decides each new call of a tool that runs inside the CLI from its input instead, such as Bash
commands on an allow list or file paths that must stay in one directory. The adapter calls it in its
PreToolUse hook with the RunModel context, the tool's CLI name, and the input the hook receives:
- a
ToolPolicyis the call's policy; withoutrequiresApproval,non_idempotent_writeandunclassifiedwait for approval; new ClaudeCodeToolDenial(reason)denies the call, and the model seesPreToolUse:<tool> hook error: <reason>;undefinedkeeps the session'stoolPoliciesanddefaultToolEffect;- a thrown error denies the call with the error's message.
It is not asked about custom or source tools, or about a resumed call that already has a final
record, and the allowedTools check keeps using the session's policies. Since 0.5.0.
toolSources takes the core's tool sources, such as ConnectorToolSource from
@superdurable/dex-agents/connectors, which offers reviewed Connector operations. See the core's
Connector tools
for what the manifest decides.
import manifest from "@superdurable/connector-stripe/dex-connector.json" with { type: "json" };
import { CreateRefundInputSchema, newProjectConnection } from "@superdurable/connector-stripe";
import { ConnectorToolSource, connectorTool } from "@superdurable/dex-agents/connectors";
const stripe = newProjectConnection(project, "billing-stripe");
const flow = createClaudeAgentSessionFlow(
{
options: buildOptions,
customTools: [lookupOrder],
toolSources: [
new ConnectorToolSource([
connectorTool(stripe.operations.createRefund(), stripe.reference, {
manifest,
inputValidator: CreateRefundInputSchema,
}),
]),
],
},
"SupportAgent",
);- The runner lists every source in each
RunModelattempt and serves its tools from the custom tool server, after the custom tools, with the source's name, description, and JSON Schema. The model seesmcp__tools__<name>; policies, interrupts, and records use<name>. - Every call of a source tool defers, reads included, because the source needs its tool Step: a
connector call derives its Call ID from the Step execution ID and keeps its fence marker in the
Step's heartbeat checkpoint. The source builds the call from the CLI's final input, after your
hooks'
updatedInput, so it decides the effect class and approval, andtoolCallStepOptionsgives the tool Step the operation's timeout and retry policy. AtoolPoliciesentry for a connector tool changes only its approval. - The tool Step runs the call through the source's
executeDeferredToolCall, with the Step's context, heartbeat checkpoint, and attempt. The resumed CLI receives the final record through the custom tool server, as for a deferred custom tool; the source never runs inside the CLI. - Source tools are served without MCP annotations, so the CLI runs them one at a time in the model's
order. Writes of one reply therefore run one at a time: a later deferrable call is denied inside the
turn with a message that asks the model to read the earlier result first. It leaves no record, so
the core's
skippedmarker never appears here. - Definition fails with a
TypeErrorwhen two sources share asourceName, or a source'sdeclaredToolNamesrepeat a custom tool, a Claude Code tool, or another source's tool. The same names listed at run time fail thatRunModelattempt. AnallowedToolsentry for a source tool fails the attempt, because the call must defer. - The runner polls no tool tasks and asks no questions for a source. A source call that starts a task
records
failedfor areadcall andoutcome_unknownotherwise; one that asks a question recordsfailed. A source whose listing fails or takes longer thantoolSourceListingTimeoutMs, 10 seconds by default, offers no tools in that attempt. - A source tool that the turn already called but the current listing lacks is still served, replay-only, so the resumed CLI receives its recorded result. A new call of that tool is denied in the turn ("its tool source does not offer this tool now"), so it never starts a tool Step under a new Call ID.
Pass mediaStore, the store your clients upload to, and the session accepts user messages with images. Without it,
the runner has no buildUserMessageWithImages, so sendUserMessage with images returns false, as before. See the
core's Images section for
references and stores.
import { sendAgentUserMessage } from "@superdurable/dex-agents";
import { S3MediaStore } from "@superdurable/dex-agents/s3";
const media = new S3MediaStore({ bucket: "agent-media", prefix: "sessions" });
const flow = createClaudeAgentSessionFlow({ options: buildOptions, customTools: [lookupOrder], mediaStore: media }, "SupportAgent");
const reference = await media.putImage(await readFile("chart.png"));
await sendAgentUserMessage(client, flow, "session-42", { text: "What does this chart show?", images: [reference] });- User images. The prompt sends the message text, then each image as a Claude
imageblock,{"type": "image", "source": {"type": "base64", "media_type": ..., "data": ...}}, in reference order. The storeduser_promptitem adds"images", the wire fields of each reference, as the Python adapter writes them. - Tool result images. A deferred call's result
imagesreach the model inside itstool_result, asimageblocks after the output text: the custom tool server answers the resumed call with MCPimagecontent, which the CLI turns into Claudeimageblocks. No separate message is added. A deferred custom tool's own MCPimagecontent is uploaded in its tool Step and recorded asimages, so it replays the same way. The stored tool result message'simagesare the references the model saw. - References in storage. The CLI writes image bytes into its transcript. Before an entry reaches Dex state, the
runner replaces the
dataof each base64 imagesourcewith{"dex_image_reference": {<reference fields>}}, keeping every other key in place; this coversframeworkItem, the run state of a suspended turn, and copies such astoolUseResult. An image without a known reference, such as one an inlinereadtool returned, is uploaded first. When the CLI resumes the session, the runner reads the bytes back and restores each entry exactly, so the replayed model input is the same on any Worker. The encoding matches the Python adapter. - Only Claude
imageblocks are rewritten. Other base64 content, such as a PDFdocumentblock, or a copy that a Claude Code tool keeps in a shape of its own, such asReadof an image file, is stored as the CLI wrote it. - A store read failure fails the
RunModelattempt, which retries. An image the store refuses, or base64 that would not restore to the same text, fails the attempt too, rather than storing bytes. - The CLI also writes each image to a local file and adds a
[Image: source: <path>]text after it. That text replays as stored. - A runner without a store fails a turn whose stored history holds image references.
buildUserMessagestores the prompt as{"kind": "user_prompt", "text": ...}.RunModelcallsquery()with the prompt. From the second turn on it setsresumeto the stored Claude session ID; the SDK loads the stored entries from the adapter'sSessionStore, writes them to a temporaryCLAUDE_CONFIG_DIR, and the CLI resumes from that file. The CLI mirrors every transcript line it writes back to theSessionStore. Prompts and turn-failure notes after the last stored transcript never reached a CLI; the prompt is then one text block per stored text, oldest first, ending with this turn's prompt.- The PreToolUse hook decides each call:
- A custom
readcall without approval, or an approval-free CLI tool call:allow. - The first other custom tool call of the segment, or the first CLI tool call that needs approval:
defer. The CLI ends the segment withterminal_reason: "tool_deferred"and the call indeferred_tool_use. The runner returnsToolCallsRequestedwith that one call, the session ID, and the turn's entries so far asFrameworkRunState. The call's arguments are the CLI's recorded input, after every PreToolUse hook'supdatedInput. - Every later deferrable call of the same segment:
denywith " did not run. ... Call again by itself." The CLI resumes only one deferred call, so a second one would never get a result. The model calls it again in a later reply.
- A custom
- The session runs the call in
AwaitToolApprovalandCallToolorCallSingleAttemptTool. For a custom tool that runs the handler; for a CLI tool it recordsapprovedCliToolOutput.ResumeTurnthen startsRunModelagain. - The resumed
RunModelstarts the CLI without a prompt and keeps its input open. The CLI finds the pendinghook_deferred_toolentry, runs PreToolUse for that call again, and the hook answers from the final record: a custom tool isallow, and the wrapped MCP handler returns the recorded output, failure, rejection, or unknown outcome without running the tool; an approved CLI tool isallow, and the CLI runs it. Its tool Step returnedisFinishedByResumedTurn, so the CLI's tool result writes the call's onetool_call_finished,failedwhen the run fails, and a segment that ends without that result writesoutcome_unknown. A rejected or unfinished CLI tool isask, andcanUseTooldenies it with "The user rejected this call: ", or the record's failure or unknown outcome text. The CLI then adds a hidden "Continue from where you left off." message and makes the next model call. The runner closes the CLI's input when the segment's result arrives. - When the CLI produces the final answer, the runner returns
TurnCompleted. Each assistant API message and each tool result becomes one stored message. ItsframeworkItemis{"kind": "claude_transcript", "session_id": ..., "entries": [...]}; other entries, such as the CLI's copy of the prompt, the deferral attachment, and the hidden continuation, ride along with the next message. The entries of all messages, in sequence order, are the Claude transcript.
The wrapped MCP handler matches a call to its permission decision by _meta["claudecode/toolUseId"],
which the CLI sends on every tools/call, so an application hook that rewrites the input cannot make
a recorded write look new. A call without a decision for that ID does not run.
Usage comes from the result message of each segment: inputTokens include cache reads and cache
writes, cachedInputTokens are cache reads, modelCalls counts distinct assistant message IDs,
subagents included, and reasoningTokens stay 0 because the result does not report them. Text and thinking
deltas of the main conversation stream to AgentEvents as text_delta and reasoning_delta. Inline
tool calls write tool_call_started and tool_call_finished; the session writes them for deferred
calls. Group live text by (turnId, stepExecutionId, attempt) as the core README describes.
A spendLimit is checked once per segment, and its usage arrives when the segment ends, so one segment can
overshoot it. Set maxBudgetUsd in the options too to bound a single segment.
The session emits a chat span for every RunModel attempt, as the
core README
describes. Here one span covers one turn segment. It is CLIENT with usage when modelCalls is 1,
and INTERNAL without usage when the segment made several model calls, such as a call to an inline
read tool and the answer after it. The runner returns modelCallMetadata with only what the CLI
reports, and omits every other field:
| Field | Source | Set when |
|---|---|---|
requestModel |
model of the CLI's system init message: the model the CLI requests for the main conversation, after it resolves aliases and its default. The options' model when no init message arrived. |
No subagent made a model call in the segment |
responseModel |
message.model of the assistant messages |
Every assistant message of the segment, subagents included, names the same model |
providerName |
provider of result.modelUsage[requestModel]. firstParty becomes anthropic, bedrock and mantle become aws.bedrock, and vertex becomes gcp.vertex_ai. Other values, such as foundry, anthropicAws, and anthropicGoogleCloud, pass through unchanged, because no well-known value names them unambiguously. |
requestModel is set and the CLI reports a provider other than gateway for it |
responseId |
message.id of the assistant messages |
The segment made exactly one model call, subagents included |
finishReasons |
delta.stop_reason of the main conversation's message_delta stream event |
The segment made exactly one model call, subagents included |
- A subagent can request a different model, so a segment with subagent model calls has no
requestModeland noproviderName. Its calls count inmodelCalls, so the segment's span isINTERNAL. gatewaymeans the CLI signed in to an enterprise gateway, which relays to a backend the CLI does not name, so the span has no provider.- The result's
stop_reasonis not a finish reason: a deferred segment reportstool_deferred, which is Claude Code's reason, not the model's. - When the CLI falls back to another model after an overload or a refusal,
requestModelstill names theinitmodel.responseModelnames the model that answered, or is omitted when the segment's answers came from different models. - The adapter never derives a provider from a model name, an environment variable, or a base URL.
Without a
providerinmodelUsage, the span has none.
test/integ/real-claude-cli.integration.test.ts runs the bundled Claude Code CLI 2.1.316 against a
local scripted Messages API. It and the Python adapter's tests/test_real_claude_cli.py settle this
behavior:
- Resume sends no prompt. With
--resumeof a transcript that ends in a pendinghook_deferred_toolentry, the CLI resumes the deferred call on startup without any user message. It reruns PreToolUse with the call's original input, runs or denies the tool, and appends anisMetauser entry "Continue from where you left off." before the next model call. A host must keep stdin open until the result. Sending an empty prompt (content: []) as well starts a second turn and a spurious extra model call. permissionModeneeds no pinning. Defer and resume work with the mode unset (the CLI reportsdefault),default, anddontAsk. The adapter passes the application's mode through and rejects the modes that skip it.- One reply can defer several calls. The CLI writes one assistant message per content block, so
its solo check sees one tool use and honors every
defer. The result names only the last deferred call, and a resume reruns only that one. The adapter therefore defers one call per segment. updatedInputapplies before the deferral. The CLI waits for every PreToolUse hook, appliesupdatedInput, and records that input in the deferral anddeferred_tool_use.input. Hooks receive the original input.- Tool use IDs reach MCP servers. Every
tools/callcarries_meta["claudecode/toolUseId"]. - A hook denial reaches the model with a prefix,
PreToolUse:<tool> hook error: <reason>. A rejected custom tool call is therefore allowed and the adapter's MCP server returns the rejection, so the model sees exactly the rejection text. A rejected CLI tool isask, which sends it tocanUseTool, whose denial message reaches the model without a prefix. - The in-process MCP server must be declared at startup. The TypeScript SDK declares in-process
servers only in its initialize request, and the resumed call starts before that request is applied:
the CLI then reports
terminal_reason: "tool_deferred_unavailable". The adapter therefore declares the server in--mcp-configas{"type": "sdk", "name": ...}, as the Python SDK does, and its tools exist when the resumed call runs. --await-initializeis a defense. With the custom tools on a loopback HTTP MCP server, an earlier design, the CLI skipped PreToolUse for the resumed call and askedcanUseToolin two of three runs. With the SDK server declared in--mcp-configthis was not reproduced: ten suite runs resumed 28 calls, also with the SDK's stdin held back for three seconds, and the hook decided every one. The adapter still passes--await-initializeand answerscanUseToolfrom the record too; a fake-CLI test covers that fallback. UnderdontAskthe CLI would deny an undecided call without askingcanUseTool, so there only the flag would help.
Recorded in Dex state, never repeated after a crash:
- The transcript of every completed turn, and the entries of a turn whose call is deferred.
- Every deferred custom tool call and source tool call, and its outcome: output text, failure, rejection, or unknown outcome.
- Approvals and rejections of every deferred call, including CLI tools.
Not recorded, and repeated when RunModel reruns a segment after a crash:
- Model calls and inline
readtool calls. - Tools that run inside the CLI, such as
Bash,Write,Edit, andAgent, including approved ones. They are at least once. - Files the CLI changes in
cwd. Pointcwdat storage that survives the Worker, such as a sandbox owned by the session, for file-editing agents. - Subagent transcripts. Resume needs only the main transcript.
- Claude Code's
last-promptentries. Each names the leaf a resume continues from, and one stored from an earlier segment would make a later resume skip every entry after it. Entries that adapter 0.4.0 stored are dropped when a segment reads the transcript.
When a turn fails after deferred calls ran, the core stores a turn-failure note that lists them. The failed turn's transcript is not stored, so the next turn resumes the last completed turn's Claude session and sends the failed prompt, the note, and its own prompt as three text blocks.
- One deferred call per segment. A reply that asks for two writes costs an extra model call: the second write is denied and the model asks for it again alone.
- A deferrable call inside a subagent is denied with a message that asks the model to call it from the main conversation.
- The resume behavior above was observed with Claude Code CLI 2.1.316. Rerun the real-CLI tests when
upgrading
@anthropic-ai/claude-agent-sdk; a CLI without--await-initializefails at startup. - A turn segment must finish within
RunModel's method timeout, 15 minutes by default. SetrunModelLimitin the session options to raise it. A failed segment reruns whole. - Recorded custom tool results keep text only; other content becomes a placeholder such as
[image content omitted]. - The adapter's PreToolUse matcher runs beside your own PreToolUse hooks. Do not return permission
decisions from your hooks for tools the adapter defers. The adapter does not call your PreToolUse
hooks for a resumed call that already has a final record: the call passed them when it was deferred,
and a later deny would hide its recorded result. A resumed tool that runs inside the CLI, such as
Bash, receives its recorded input instead, the input that was approved. - The custom tool server runs a tool only under the tool use ID and tool name that the hook permitted. A deferred call that the hook did not defer fails the segment, which then retries.
- A recorded call replays only as its own tool, a custom or source record never as a tool inside the
CLI, and at most once per segment. Another tool, or a second decision, under its tool use ID is
denied and never runs. Every allow of a replay carries the recorded arguments as
updatedInput. Only the pending deferred call replays: a recorded call whosetool_resultthe stored transcript already holds, because an earlier segment resumed it, is denied. - A PreToolUse allow is the call's one authorization, so
canUseTooldenies every later request for that tool use ID.canUseToolallows only a resumed call whose hook did not run, once, for its own tool and kind (served or inside the CLI), with the recorded arguments; the input the CLI passes is never used. - An inline custom tool is decided by its name and policy, not its input: the MCP call runs with the
input the CLI sends after every hook's
updatedInput, which may differ from the input the adapter's hook saw. Check input in your own PreToolUse hooks or in the tool. - A deferred call is rebuilt from the final input the CLI reports, after every hook's
updatedInput. Its policy, approval, approval interrupt, and tool Step all use that input, so what runs is what was approved. - Each tool use ID gets one PreToolUse decision per segment: allow, deny, defer, or a replay. A later PreToolUse for the same ID is denied and never replaces that decision.
- On the first turn of a session Claude Code also writes its usual local transcript under
CLAUDE_CONFIG_DIR(~/.claudeby default). The adapter never reads it; setCLAUDE_CONFIG_DIRinenvto choose where it goes. Later segments run in a temporary directory that the SDK removes, unless the Worker dies first. - Dex compaction is not available. Claude Code keeps its own transcript and compacts it inside the CLI,
so the adapter has no summary to put in the model input, and
createAgentSessionFlowthrows for acompactionpolicy. The stored transcript keeps every entry, Claude Code's compaction entries included, and retention never deletes it, because no Dex summary covers it. A long session therefore ends at the state limit, not the context window.
DEXCLI=/path/to/dexcli ./ci.shci.sh builds the core, runs npm ci (with the optional native Claude Code binary),
npm run typecheck, and npm run test:integration, which builds with tsc and runs node --test
over dist/test/integ against private dexcli dev --engine zd engines from
@superdurable/dex-agents/testing. It sets CLAUDE_AGENT_SDK_REAL_CLI_REQUIRED=1, so a missing
native binary fails the real-CLI suite. After npm ci --omit=optional that suite is skipped and the
fake-CLI suites still run.
No test calls a real model. The real-CLI suite starts the bundled claude binary with
ANTHROPIC_BASE_URL pointing at test/fixtures/mock-anthropic-api.ts, a scripted Messages API on
127.0.0.1 that streams Server-Sent Events, a dummy ANTHROPIC_API_KEY, temporary HOME and
CLAUDE_CONFIG_DIR, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1, and HTTPS_PROXY pointing at a
local proxy that refuses every connection. The suite fails if the CLI requested anything the mock
does not serve or tried the proxy.
The other suites use test/fixtures/fake-claude-cli.ts, a scripted CLI that the SDK starts through
pathToClaudeCodeExecutable (a .js path, which the SDK runs with node). It copies the real CLI's
observed behavior and speaks this subset of the SDK's stream-json protocol, taken from
@anthropic-ai/claude-agent-sdk 0.3.316 and the bundled CLI:
| Direction | Message |
|---|---|
| SDK to CLI | Arguments --output-format stream-json --input-format stream-json, --resume=<id>, --permission-prompt-tool=stdio, --permission-mode=<mode>, --session-mirror, --include-partial-messages, --await-initialize, and every --mcp-config (merged); CLAUDE_CONFIG_DIR and CLAUDE_SECURESTORAGE_CONFIG_DIR in the environment, CLAUDE_CONFIG_DIR a temporary directory holding projects/<key>/<id>.jsonl on resume |
| SDK to CLI | control_request initialize with hooks.PreToolUse[].hookCallbackIds, sdkMcpServers, and sdkMcpServerManifests (tool lists); the CLI answers control_response success. Other control requests get an empty success |
| SDK to CLI | user messages with a prompt string or text blocks for a new segment; none for a resumed segment. Stdin closes after the segment's result |
| CLI to SDK | control_request mcp_message with JSON-RPC tools/call carrying _meta["claudecode/toolUseId"] (and initialize, notifications/initialized, tools/list when no manifest came); the SDK answers {"mcp_response": ...}. HTTP MCP servers from --mcp-config get the same JSON-RPC over POST |
| CLI to SDK | control_request hook_callback with a PreToolUse input (tool_name, the original tool_input, tool_use_id); the SDK answers with hookSpecificOutput.permissionDecision and optionally updatedInput, which the CLI applies. Several decisions combine as deny over defer over ask over allow; a denial's tool result reads PreToolUse:<tool> hook error: <reason> |
| CLI to SDK | control_request can_use_tool when no hook allowed, denied, or deferred the call; the SDK answers behavior allow or deny |
| CLI to SDK | system init per turn naming the requested model, stream_event content_block_delta text deltas, one assistant message per content block naming a dated response model, a stream_event message_delta with the stop reason after each model call, user tool results, and result with usage and modelUsage keyed by the requested model with provider firstParty, or with terminal_reason: "tool_deferred" and the last deferred call in deferred_tool_use; an error result has subtype: "error_during_execution" and exit status 1 |
| CLI to SDK | transcript_mirror frames {filePath, entries} for every transcript line, including a last-prompt entry naming the leaf before each result |
| Transcript | user, assistant, and attachment hook_deferred_tool entries with uuid and parentUuid. Every defer is honored. On --resume, the last deferral without a tool result runs at once, without user input, from the original tool_use input through every hook again. It reports tool_deferred_unavailable when its MCP server was not declared in --mcp-config (an SDK server, or an alwaysLoad HTTP server), runs without hooks when --await-initialize is missing or FAKE_CLAUDE_RESUME_WITHOUT_HOOKS=1, and then appends an isMeta "Continue from where you left off." entry |
The scripted model, in the fake CLI and in the mock Messages API, reads commands from the last text
block of the turn's last real prompt; the mock looks for them after dex-script: :
say <text>answerssaid: <text>.recallanswers with every earlier prompt of the session.call <tool> <json>calls tools;&&joins calls into one reply. A refused call that must run alone is called again by itself. A trailing;; pause <gate>waits at a gate before answering;;; failfails once every call has a result (fake CLI only). A trailing;; reuse <tool> <json>then calls that tool under the tool use ID of the turn's first call and answersreused <tool>: <result>(fake CLI only).stall <gate>callslookup, streams text, and waits at a gate before answering (fake CLI only).- An
Agenttool call runs a subagent: one assistant message whoseparent_tool_use_idis the call, answered by another model and kept out of the main transcript (fake CLI only). failends the turn with an error result (fake CLI only).
The fake-CLI suites cover the plan's matrix: a read tool and history replay through a resumed
session, an approved write that survives a Worker restart and runs once, a rejected write, a repeated
resolve_interrupt, SIGKILL during a single-attempt write, SIGKILL during RunModel, and the
snapshot after a turn. They also cover idempotent retries with one key, an idempotent write whose
attempts run out, a tool error result, approved and rejected Bash, two writes in one reply, an
input-rewriting application hook, a rewritten input that fails the tool's schema, a resumed call
answered through canUseTool when the hooks are skipped, custom tool names that collide with Claude
Code tools, SIGKILL during
a resumed segment, a CLI error result, and a resumed turn that fails after a write and leaves a note
for the next prompt. test/integ/tracing.integration.test.ts runs an in-process Worker with an
in-memory span exporter and checks the chat span metadata of a one-call answer, a two-call read
tool turn, a deferred write and its resumed answer, and a segment with a subagent call, plus the
provider name mapping. The real-CLI suite covers a read tool with a resumed second turn, an approved
write resumed without a prompt with permissionMode unset and with dontAsk, a rejected write, two
writes in one reply, two connector writes in one reply under dontAsk, an input-rewriting hook, and
approved Bash, also with a command that an application hook rewrote. The fake-CLI suites also cover an application hook that denies a resumed call, and a
Bash command that an application hook rewrote and that resumes with the approved input.
test/integ/connector-tools.integration.test.ts runs connector tools from a copy of the core tests'
fake ledger Connector (test/fixtures/fake-ledger.ts): a Query in its tool Step, a Mutation with
manifest approval across a Worker restart, a session policy that changes only approval, an approval
predicate on hook-rewritten input, a retry: safe Mutation retried with the same Call ID, with and
without a SIGKILL, uncertain writes recorded outcome_unknown, a rejected branch and bad arguments
that never reach the provider, two writes of one reply in the model's order, an application hook
that denies on resume, an allowedTools entry for a source tool, an unexposed operation, refused
name collisions, the operation's tool Step options, and a dynamic source whose listing empties or
fails on resume: the recorded call still replays, and a new call of the vanished tool is denied. It
also checks that a source call asking a question records failed, and one starting a tool task records
failed for a read and outcome_unknown for a write. test/integ/tool-call-gate.integration.test.ts
calls the custom tool server over an in-memory MCP transport with a tool name that differs from the
permitted one, hands the gate deferrals that its PreToolUse hook never deferred or deferred under
another ID or tool, and replays records under another tool, such as a custom or source record as
Bash, or twice in one segment. They also ask canUseTool after the hook allowed a call, with any
input or kind, ask it twice for a resumed call whose hook did not run, and repeat PreToolUse for a
decided ID. deferred-tool-calls.integration.test.ts reuses a resumed call's ID in its segment, under
another tool, and in a later segment.