Skip to main content

Claude Code SDK agent

@flow-state-dev/claude-code/sdk — run a Claude Code agent in-process and watch it work through your flow's item stream. The agent's messages, reasoning, tool calls, and sub-agents become flow-state-dev items as they happen, and its session carries across requests.

It is one of three harnesses flow-state-dev ships. Coding agents covers what they have in common — the handle they all return, and the configuration contract underneath cwd, resume and onSession. Codex and Cursor are the other two. This page is the Claude Code one.

When to use it​

Reach for this when a flow needs a real agent in the loop, not a fire-and-forget dispatch: an assistant that reads and edits files, runs commands, searches the web, and reports back as it goes, with each step visible in the devtool. Because the run is a block, a sequencer can chain it, a router can branch on its result, and the next request can resume the same agent session.

The agent owns its own loop. flow-state-dev does not drive it step by step or re-implement its tools. It runs the Claude Code Agent SDK, observes the stream, and translates each message into a canonical item. You get the SDK's full tool suite and agentic behavior, with flow-state-dev's state, provenance, and streaming wrapped around it.

Installation​

The SDK is an optional peer dependency, so the package stays installable for the /cli path without it:

pnpm add @flow-state-dev/claude-code @anthropic-ai/claude-agent-sdk

The Agent SDK bundles its own binary, so there is no separate CLI to install. It needs Anthropic credentials in the environment at runtime.

Quick start​

A capability is a reusable bundle you attach to a block. Installing this one lets a generator hand work to the agent as a tool:

import { generator } from "@flow-state-dev/core";
import { createClaudeCodeAgentCapability } from "@flow-state-dev/claude-code/sdk";

const orchestrator = generator({
name: "orchestrator",
model: "openai/gpt-6-luna",
uses: [createClaudeCodeAgentCapability()],
});

As a sequencer step​

When the host decides to run the agent (rather than letting a model choose), use the block directly:

import { harnessEnv } from "@flow-state-dev/core";
import { claudeCodeAgent } from "@flow-state-dev/claude-code/sdk";

const env = harnessEnv({ pass: ["PATH", "HOME", "CLAUDE_CODE_OAUTH_TOKEN"] });
const agent = claudeCodeAgent({
env,
systemPrompt: {
type: "preset",
preset: "claude_code",
append: "You are a careful refactoring assistant.",
},
allowedTools: ["Read", "Grep", "Edit"],
});
// seq.step(agent) with input { prompt: "Tidy the imports in src/." }

The block returns the neutral harness handle with source: "claude-code/sdk", plus two fields only Claude Code can fill: resultSubtype (the SDK's terminal result code) and toolsObserved (the distinct tool names the run exercised).

What it emits​

As the agent runs, the block translates the SDK stream into items:

SDK outputflow-state-dev item
Assistant textmessage (streamed token by token when partial messages are on)
Extended thinkingreasoning, when the SDK returns any text. Thinking with the text withheld emits nothing.
Tool call and its resulttool_output, with the tool call's id, name, and arguments. While it runs, a transient status carries the call's description for Bash, or Using <tool>… for any other tool, and it clears when the last open tool finishes.
Sub-agent (Task/Agent)a container grouping the sub-agent's items. While it works, a transient status shows the sub-agent's summary if it has one, otherwise its description followed by the last tool it used, such as Refactor imports (Edit). Status text is cut to 120 characters. A sub-agent with neither a summary nor a description shows no status.
Run starttransient status: Starting Claude Code…, shown from the moment the run starts.
API retrytransient status each time Claude Code retries a failed API call, for example Claude Code is retrying a failed API call (attempt 2 of 5, in 5 s)…. The , in 5 s part is left out when the SDK reports no delay.
History compactiontransient status: Claude Code agent compacted its context. Compaction is Claude Code summarizing a long conversation to free up space.
Run enda status kept in the session's history, saying how long the run took: Claude Code agent run completed in 5 mins, 24 seconds. A run that fails ends with a kept status that says it errored and why, Claude Code agent run errored (<reason>)., besides the error item.
Errorserror

The run-start, API retry, and compaction statuses clear on the first output Claude Code produces after them: an assistant message, a streamed token, a tool result, or the run's result. Nothing else clears them. Another status replaces them straight away, such as a tool's Using <tool>….

The run-end duration is rounded to the nearest second and written with units: 1 min, 1 second. A run of an hour or more shows hours and minutes only (1 hr, 3 mins). Under half a second reads less than a second.

Every item carries provenance, so the devtool shows the full trace of what the agent did.

Session continuity​

The block persists the SDK session id in session state and resumes it on the next request, so a follow-up prompt continues the same conversation:

const runs = ctx.session.state.sdkAgentRuns ?? [];

Continuity runs through a BindingProvider, the framework's mechanism for holding a live handle across requests rather than a parallel store. The default provider is thin because the SDK resumes cheaply by id; pass your own to hold a heavier resource (an open connection, for example).

Turning it off for background work​

Background work runs in a dispatch run, a session separate from the request that started it. If you dispatch the agent into one, set detached: true:

const agent = claudeCodeAgent({ detached: true });

Each job is then one run. Nothing is written to session state, and no prior SDK conversation is resumed — a second job landing in the same session begins a new agent run. What the run did is still recorded: that session's own item stream holds its messages, reasoning, and tool calls in order, which is what you read the run back from.

The session id does not disappear. The option governs session state and automatic resume, not the run's own result: the handle the block returns still carries the SDK sessionId it observed. When the agent runs as a task-board worker, that handle is the worker's output, and the board writes the output onto the task when it settles — so the id is persisted there. Worth knowing if you are reasoning about data retention, or if you plan to resume a run by hand later.

You have to pass it there. A worker that runs in a dispatch run may share that session with other rows, so two blocks declaring the same session-state key would overwrite each other. The task board refuses to build a dispatch whose block declares session state.

That refusal reads the worker block, the blocks composed inside it, and the session state a capability the block uses declares for itself. What it cannot read is a schema a capability's preset adds, since a preset's contribution depends on a runtime opt-out. So pass the option wherever you attach the agent — the capability form takes the same one:

createClaudeCodeAgentCapability({ detached: true });

See Dispatched work for how a dispatch run is started and read back.

Continuing a run on the background path​

Background jobs start fresh by default. Sometimes you want the next one to pick up where the last one left off — the job asked a question, a person answered it, and the follow-up should continue that conversation rather than start over knowing only what its prompt says.

Two options do that, and they work as a pair. resume says which session to continue; onSession tells you which session the run turned out to be in.

claudeCodeAgent({
detached: true,
// Which conversation to continue. Read it from somewhere you control —
// never from the block's input, which a model can see and set.
resume: (ctx) => lastSessionFor(ctx),
// Called during the run, the moment the agent names its session.
onSession: (id, ctx) => recordSessionFor(ctx, id),
});

Return null or "" from resume and the run starts fresh, which is what you want on the first attempt.

onSession fires during the run rather than after it, and that timing is the reason it exists rather than reading the id off the returned handle. A run that gets cancelled — a deadline, a shutdown — never returns a handle at all, and that is exactly the run you most want to continue. The hook is called as soon as the agent names its session, before the run does anything that could fail.

What the hook reports is the session the agent confirmed it is in, which is not always the one you asked for. Hand it an id the agent can no longer find and the run may answer with a session of its own, or end without naming one. So record what the hook gives you, and treat "the hook never fired" as "there is nothing to continue" — the next attempt then starts fresh on its own, instead of asking for a session that is gone over and over.

Both options are background-path only. In session, the block already resumes the last run and records the new id itself, so a second answer to either question would be two owners of one decision. Passing them without detached: true throws when you build the block, not at run time.

Where the run works​

By default the agent runs in whatever directory your server process is running in. That is fine when the run is editing scratch files. It stops being fine the moment two runs need to work on different copies of something, or a run needs to work on a checkout that is not the one the server lives in.

cwd gives a run its own directory:

import { mkdir, mkdtemp } from "node:fs/promises";
import { join } from "node:path";

const CHECKOUT_ROOT = "/var/agent-checkouts";

const agent = claudeCodeAgent({
cwd: async () => {
// `mkdtemp` creates the unique leaf, not the parent — it fails ENOENT if
// the root is not already there, which on a fresh machine it is not.
await mkdir(CHECKOUT_ROOT, { recursive: true });
return mkdtemp(join(CHECKOUT_ROOT, "run-"));
},
// A fresh directory per run only makes sense with a fresh conversation.
// See below.
detached: true,
});

It is a function rather than a string on purpose. A flow is built once and then serves many runs, so a fixed directory would be the wrong shape. This resolves per run, just before the agent starts, and can return a promise as it does here. It is handed the block's context and never the prompt, for the reason Coding agents gives.

A throwaway directory and a resumed conversation do not go together, which is why detached: true is in the example. By default the agent keeps conversation state and hands the SDK a resume handle from the previous run in the same session. Pair that with mkdtemp and the second invocation resumes a conversation created in a directory that has nothing to do with the tree it now runs in — the agent picks up mid-task in an empty checkout. Either start fresh each run, as here, or keep a stable directory when you want resume.

The run's file tools address relative paths inside the directory. So does the record of what the run touched, if you have recordWork on (below): src/a.ts written by a run in one checkout and src/a.ts written by a run in another are two entries, not one.

A working directory is not a sandbox. The run can still address an absolute path outside it, and that operation is recorded at the path it actually reached. The file record is a log of what the run's tools did, not a fence around where they may go.

Reusing a directory across runs​

The example above throws its directory away. Sometimes you want the opposite — runs that belong together sharing a checkout, so a second attempt picks up where the first stopped.

If that work arrives as rows on a task board, you do not have to build this. The harness manager derives a checkout per task, provisions it, and holds it under a lease for the length of the run. The rest of this section is the hand-rolled version, for a host driving the agent itself.

It means building the path out of a value, and it is worth being careful about which value and how.

import { mkdir } from "node:fs/promises";
import { createHash } from "node:crypto";
import { isAbsolute, join, relative } from "node:path";

function segment(value: string | undefined): string {
// Absence is `0`; a present value is `1` followed by a SHA-256 digest of its
// UTF-16 code units. Exactly 65 characters whatever the id's length, which
// is what keeps a long session id from overflowing the 255-character
// filename limit.
//
// Digest the CODE UNITS, not the UTF-8 bytes. UTF-8 cannot represent a lone
// surrogate, so hashing `value` directly maps "\ud800", "\ud801" and a
// literal "�" onto one digest — a collision anyone can produce on purpose.
return value === undefined
? "0"
: `1${createHash("sha256").update(Buffer.from(value, "utf16le")).digest("hex")}`;
}

function checkoutFor(tenantId: string | undefined, key: string): string {
const dir = join(CHECKOUT_ROOT, segment(tenantId), segment(key));
// Belt and braces. Encoding already makes escape impossible; this costs a
// line and fails loudly if the encoding is ever swapped for something weaker.
const rel = relative(CHECKOUT_ROOT, dir);
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(`refusing a checkout outside ${CHECKOUT_ROOT}`);
}
return dir;
}

checkoutFor derives a path and nothing more — deriving is pure and testable, provisioning is the resolver's job. Wire both halves of the identity in, and create the directory before handing it over:

const agent = claudeCodeAgent({
cwd: async (ctx) => {
const dir = checkoutFor(
ctx.session.identity.tenantId,
ctx.session.identity.id,
);
// The SDK spawns a process into this directory, so it has to exist —
// Node fails the spawn with ENOENT otherwise. `recursive` is what makes
// reuse work: the second run finds the first run's tree instead of
// erroring on a directory that is already there.
await mkdir(dir, { recursive: true });
return dir;
},
});

Keeping two runs out of one tree​

A reused checkout is shared mutable state. Actions run concurrently unless you say otherwise, and the resolver above creates the directory without claiming it, so two runs for the same session get the same tree and their edits and Git operations race in it.

Declare a concurrency policy on the action that runs the agent:

defineFlow({
kind: "coding",
actions: {
// `queue` serializes on the session — the same value the checkout is
// derived from, so one run finishes in the tree before the next starts.
// `reject` instead if a second request should be dropped rather than
// waited on.
implement: { block: codingPipeline, concurrency: "queue" },
},
});

The two keys lining up is the point: derive the checkout from the session and arbitrate on the session.

A policy arbitrates dispatches, which covers one of the three ways two runs can end up in one checkout, and a second when the workers share a lease backend:

HowCovered?
Two requests, one session.Yes. That is what the policy is for.
Two agent invocations inside one dispatch.No. A generator's tool calls in a single model step run concurrently, so a model holding this block as a tool can call it twice and both land in the same directory, inside one dispatch where the policy never sees them.
Two workers, one session.Only when the workers share a lease backend. By default the arbiter keeps its keys in the running process, so routing execution to external workers leaves both able to land in one checkout. A queue adapter that supplies a lease backend every worker shares (WorkerAdapter.leaseBackend) lines them up on the same session key.

So the recipe is safe for a single-instance host, or workers that share a lease backend, running the agent as a step, one invocation per dispatch. Outside that, derive a fresh directory per run — or let the harness manager hold the lease.

Rules for deriving the key​

Encode, don't validate. A validating grammar has to enumerate every way a string can misbehave as a path, and that list is longer than it looks: separators and .. are the obvious two, but Windows also strips trailing dots (so acme and acme. are one directory), reserves CON, PRN, AUX, NUL, COM1…LPT9 as device names that cannot be directories at all, and folds case. Every one of those is a value two different tenants could hold. A derived segment sidesteps the list: its output alphabet contains nothing any filesystem treats specially.

Hash the code units, not the UTF-8 bytes. A JavaScript string is a sequence of UTF-16 code units, and not every such sequence is valid Unicode: a lone surrogate like "\ud800" is a legal JS string that JSON will carry to your server. UTF-8 has no representation for one, so anything that transcodes through it — Buffer.from(value, "utf8"), or passing the string straight to createHash().update() — substitutes the replacement character, and "\ud800", "\ud801" and a literal "�" all come out identical. Three distinct session ids, one working tree.

Bound the output, which is why this is a digest. Filenames stop at 255 characters, and any encoding that preserves its input grows with it — hex of UTF-16 code units runs to four characters each, so a 64-character session id produces a 257-character component and mkdir fails with ENAMETOOLONG. Ids that long are ordinary. A digest is a fixed 65 characters for every input. The trade is real: a reversible encoding gives provable distinctness and stays readable, a digest rests on SHA-256 and tells you nothing about whose checkout it is. Distinct ids give distinct directories either way.

Never truncate. Cutting a reversible encoding to fit maps two long ids onto one segment, which is the collision the encoding was there to remove. Bound it by construction or refuse the value.

Give each value its own segment. Concatenating them into one string brings back the ambiguity the tenant is there to remove.

Encode whether a value is there, not just what it is. A missing tenant is tempting to fill in with tenantId ?? "default", but a stand-in is a value some tenant may legitimately hold, and then an un-tenanted host and that tenant edit one tree. The tag does the same job without the collision: absence has its own encoding no present value can produce. It also keeps every segment non-empty, which matters because join discards an empty one, so a run keyed on an empty id would land a level up.

Use ctx.session.identity.tenantId — the authenticated value the server resolved. It is separate from ctx.session.identity.id, which stays the bare session id the caller passed: two tenants can hold the same one, which is why the framework namespaces its own session storage by tenant and why a path built from the session alone puts both tenants in one checkout.

If your key is already something you control and know to be safe — a numeric job id, a UUID — the encoding is close to a no-op and you can skip it. Encode by default anyway: the moment the key starts coming from somewhere else, the rules you would have to remember are a list nobody finishes. And prefer a key your own code assigned over one that arrived with the request.

Configuring the run​

These options travel with cwd. All are unset by default.

import { harnessEnv } from "@flow-state-dev/core";
import { claudeCodeAgent } from "@flow-state-dev/claude-code/sdk";

// One checkout per invocation, shared by both resolvers. They receive the same
// context object, so a WeakMap keyed on it hands them the same directory and
// releases it when the run is done.
const checkouts = new WeakMap<object, Promise<string>>();
const checkoutFor = (ctx: object) => {
const existing = checkouts.get(ctx);
if (existing) return existing;
const fresh = allocateCheckout();
checkouts.set(ctx, fresh);
return fresh;
};

claudeCodeAgent({
cwd: (ctx) => checkoutFor(ctx),
settingSources: ["user"],
settings: { disableAllHooks: true },
env: { ...harnessEnv({ pass: ["PATH", "HOME", "ANTHROPIC_API_KEY"] }), CI: "1" },
sandbox: async (ctx) => ({
enabled: true,
filesystem: { allowWrite: [await checkoutFor(ctx)] },
}),
// Tools this process serves to the run, grouped by server: a value, or a resolver per run.
mcpTools: async (ctx) => ({
tracker: { tools: await trackerToolsFor(ctx), close: async () => {} },
}),
uses: [myCapability],
onErrored: async (error, ctx) => {
await releaseWhateverThisRunHeld(ctx);
},
});

settingSources picks which filesystem settings the run loads: "user", "project", "local". Leave it out and it loads all three, the way the CLI does. This is the one to read twice. "project" is what makes a run read CLAUDE.md and .claude/settings.json out of its working directory. If that directory is one your server assembled out of resources your own users can write, then those files are user input, and the run reading configuration from them means your users configure your agent. Pass [] to load none, or list only the sources you control.

settings is a settings layer of your own for the run, in Claude Code's settings.json shape, forwarded as given. It sits above every source settingSources loads, so a key here wins over the same key in the repository's .claude/settings.json. Leave it out and the run gets none.

The case it's for is a repository you trust. "project" loads its CLAUDE.md and skills, which is what you want, and also its hooks: shell commands Claude Code runs by itself on events like a prompt or a stop. They show in no transcript, and a run can write one into .claude/settings.json for the next run to execute. To load the conventions and keep every hook off:

import { harnessEnv } from "@flow-state-dev/core";
import { CLAUDE_CODE_PASS, claudeCodeAgent } from "@flow-state-dev/claude-code/sdk";

claudeCodeAgent({
cwd,
settingSources: ["project"],
settings: { disableAllHooks: true },
env: harnessEnv({ pass: CLAUDE_CODE_PASS }),
});

With mcpTools set, the run's settings also carry disableClaudeAiConnectors: true, whatever yours say, so the handed servers stay the run's whole MCP set.

env sets the environment variables the run's process sees. Leave it unset and the run's process starts with your server's entire process.env: every API key, token and connection string in it, readable by any shell command the model runs. Setting env replaces that environment. It does not add to it.

harnessEnv from @flow-state-dev/core builds an env out of a list of names and passes only those. Nothing is implicit, so name PATH and HOME yourself; the run's shell commands and git are found through PATH. A named variable that isn't set on the server is left out, and one set to an empty string is kept. Values are read once, when harnessEnv is called. Called inline as above, that is when the block is built, so a variable set after that never reaches the run.

To add a fixed value, spread the allowlist and set it beside it, as the example does with CI. Spreading process.env there hands the run every secret the server holds.

If you'd rather not write the list yourself, the package exports one. CLAUDE_CODE_PASS names what a run needs to work: PATH, HOME, USER, SHELL and TMPDIR, the two credential variables (ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN), and the proxy and CA settings a proxied network needs, in upper and lower case.

Include lowercase proxy names too; curl reads only http_proxy.

import { CLAUDE_CODE_PASS } from "@flow-state-dev/claude-code/sdk";

const env = harnessEnv({ pass: CLAUDE_CODE_PASS });

Give that same env to probeClaudeAccount, listClaudeModels and readClaudeUsage, so they see the account the run sees. If your runs need more, add the names to it: harnessEnv({ pass: [...CLAUDE_CODE_PASS, "GH_TOKEN"] }). Anything you pass is readable by every command the run starts, so the model can print it, and it can end up in a log or a commit.

sandbox is the Agent SDK's sandbox settings, forwarded as given. Take a value or write a resolver. The resolver form is the one that matters: the settings that confine a run name the directory it works in, and that directory is per run while one flow build serves many. A constant can say "sandboxed"; it cannot say "sandboxed to this run's workspace."

filesystem.allowWrite adds paths to what the run may write. It does not replace the default set, and it is not a fence on its own — enabled is what turns sandboxing on, and denyWrite and the permission rules are what narrow it.

mcpTools hands the run tools that run in your own process, presented to it as MCP servers. Each entry is a server, named by its key, and each tool is a name, a description, a JSON Schema for its input and a call function; the run calls it as mcp__<server>__<tool>. There is no field for an address or headers, so the run never learns where a tool's work happens or what credential it uses. Set, even to {}, it is the run's complete MCP set: no servers from settings files, plugins or the account's claude.ai connectors. The tools are allowed without asking, and disallowedTools still keeps one out. Each server's close is called when the run ends, however it ends. A coding run on a Workforce project gets its project's servers this way; see Tools a project's coding work gets.

uses installs capabilities on the agent block, the same slot every other block takes. A capability handed here that declares resources has them registered on the flow, so ctx.resources resolves for them at run time. Capabilities resolved dynamically, by a function rather than a static entry, contribute context, tools and controlTools only — a resource has to exist before the block runs.

onErrored runs after the block threw, with the error and the block's context. It does not swallow the error; the run still fails. A capability can contribute resources, state and tools but never lifecycle hooks, so this is the only way to reach one. Releasing something the run was holding is what it is for.

allowedTools and permissionMode decide what a person is asked. Under a harness manager built with approveTools: true, a call these already allow runs unasked, and only the rest wait for a person. Name in allowedTools what a run may always do (["Read"], say) and use permissionMode: "default". Under acceptEdits no file edit is asked about, and under bypassPermissions nothing is. Allow rules in a settings file that settingSources loads count too.

The prompt the run starts from​

A system prompt is the standing instruction the agent reads before your request. Leave systemPrompt out and the run starts from Claude Code's own, the same one the claude command uses, so a run started this way gets the same standing instructions as the agent you know from the terminal.

To add your own instructions on top, append them:

claudeCodeAgent({
systemPrompt: {
type: "preset",
preset: "claude_code",
append: "Commit your work with git before you finish.",
},
});

A plain string replaces Claude Code's prompt entirely. The agent then knows only what your string says, though it still has its tools. Use a string when you want that narrow agent; use append when you want to add a line.

"" starts the run with no system prompt at all. The value is forwarded to the Agent SDK as given, so any shape the SDK accepts works here, and the SDK rejects one it doesn't.

Recording what the run did​

The item stream tells you what the agent said. recordWork: true also records what it did, as ordinary state you can query afterwards.

Entries follow the run's working directory when you set one, so the paths you read back describe the checkout the run was actually given.

const agent = claudeCodeAgent({
detached: true,
recordWork: true,
});

It is off by default. Turned on, the agent declares four resource collections — a resource collection is a keyed set of small state records the framework stores and serves for you — and writes into them as the run goes:

CollectionOne entry per
observed-file-opspath the run's file-writing and file-editing tools touched: how it was touched, when, and whether the attempt applied
observed-planitem on the conversation's to-do list: its wording, its current status, the status before that, and the request that last changed it
observed-plan-listconversation whose plan could not be written in full, with the reason; and, per run, a pointer to the conversation the run's plan is in
observed-gapsthing the recorder understood and could not record, with the reason and which record it stands in for

File and gap entries are keyed as <requestId>/<invocation>. One session can host several runs over its life, and a single request can itself run the agent more than once (a generator holding it as a tool can call it repeatedly), so both halves are needed to keep one run's answer from becoming somebody else's.

Plan entries are keyed by conversation instead, as conversation/<session id>/<task id>, where the session id is the Claude Code session the run worked in. A later turn's TaskUpdate lands on the row its earlier TaskCreate wrote, so the item keeps its title. Each run writes its pointer in observed-plan-list, under <requestId>/<invocation>, once Claude Code names the session.

Reading it back​

Both records come back over the resource route, one page at a time. Scope the read with topicPrefix, and follow nextCursor while one is returned:

GET /sessions/<sessionId>/resources/observed-file-ops?topicPrefix=observed-file-ops/<requestId>/

{ "items": [
{ "topic": "<requestId>/<invocation>/work/repo/src/checkout.ts",
"storageKey": "observed-file-ops/<requestId>/<invocation>/work/repo/src/checkout.ts",
"clientData": { "lastKind": "edited", "outcome": "applied",
"lastTouchedAt": 1787021400123, "appliedCount": 2 } }
] }

Prefixing with the request id gives you everything that request did; adding the invocation narrows it to one run.

Each row's payload is on clientData. outcome has three values, not two: pending while a mutation has been seen and not yet settled, then applied or failed. A run that is killed mid-flight leaves its unsettled entries as pending, because nobody confirmed the write.

appliedCount sits beside it and counts confirmations, not attempts. Each operation is recorded twice, once when the call is seen and once when its result arrives, so a count of everything recorded would report double the work the run did. outcome describes only the last settlement, which is why a separate count is worth having: it says how many of a path's touches actually landed. Read 0 and null differently. 0 means the run touched the path and nothing applied. null means the row was written before the field existed.

The plan record is read from observed-plan. Its shape is the one every harness shares, and reading a request's plan shows how to get from a request id to its conversation's entries. Importing observedPlanCollection from this package or from @flow-state-dev/core gives the same object. A to-do item's status is the harness's own word for it, and stays empty until the harness reports one. Plan entries reach an open page as they change, rather than when the request ends.

Claude Code reports its plan item by item, so the conversation's list marker carries no count and is written only when a plan entry fails to write. It then reads incomplete, and stays that way for the rest of the conversation. A conversation whose earlier turns were recorded per run starts its plan from its next write, so a TaskUpdate to an item one of those turns created shows as a row with no title.

The plan record fills only when the agent keeps a to-do list with Claude Code's to-do tools, TaskCreate and TaskUpdate. Every run is offered them: on newer models Claude Code switches them off unless they're named in allowedTools, so the block adds them to whatever list you pass. They ask no permission, so this allows nothing else. To keep a run from them, list them in disallowedTools. A run with permissionMode: "bypassPermissions" also gets the search tools Grep and Glob. In other modes you name those yourself, because naming them approves searches outside the working directory without asking. The agent still has to choose to use them, which it often doesn't on a short job, whatever prompt the run started from. When it doesn't, the record stays empty. That is the honest answer, not a recording failure, and nothing appears in observed-gaps for it.

What the file record is, and what it isn't​

It is a log of the file operations you saw the agent's file tools perform. It is not an index of everything that changed on disk. A run that edits a file through the shell — a sed -i, a redirect, a mv, a formatter — makes no file-tool call, so nothing is recorded and that file does not appear. If you need an authoritative list of what changed, compare the working tree yourself.

Paths are recorded, contents are not. The record points at your source; it does not hold a second copy of it.

Gaps​

Recording never interferes with the run. Anything the recorder cannot handle is skipped, the run carries on, and the skip lands in observed-gaps with the reason and the raw path where there was one.

That collection is the difference between "this file was never touched" and "we saw it and could not record it". Read it alongside the other two whenever a record looks thinner than you expected.

Each gap row carries a kind — file, plan, or run — saying which record it stands in for. Match on that rather than on the wording of reason: a gap for a mutation with nothing to key on has no path to identify it by, and the reason text is prose meant for a person.

The plan is not a work queue​

A run's to-do list goes into its own record, separate from the task board that dispatched the run. An agent that decides mid-run to do five more things writes five to-do items and starts nothing. Picking one up is a separate act.

Attaching the agent as a capability takes the same option, and needs it — the capability declares the collections itself:

createClaudeCodeAgentCapability({ detached: true, recordWork: true });

See Resource collections for how collections are stored, paged, and made visible to clients.

Files that outlive the run​

A run needs a directory to work in, and that directory is usually temporary. The files it produces usually shouldn't be.

createWorkspaceAgentCapability fills the directory from your resource collections before the run and reconciles what changed back afterwards:

import { createWorkspaceAgentCapability } from "@flow-state-dev/claude-code/sdk";

const workspace = createWorkspaceAgentCapability({
root: async () => mkdtemp(join("/var/agent-checkouts", "run-")),
});

generator({
name: "coder",
model: "openai/gpt-6-luna",
prompt: "Use the workspace agent to make the change.",
uses: [workspace],
});

Each collection is mounted at its pattern prefix, so one matching artifacts/** shows up at <root>/artifacts/. collections and exclude narrow the set.

The capability wraps @flow-state-dev/workspace, which is where the mounting, the reconcile and the full outcome vocabulary are documented. Reach for the package directly when you want to project files for something other than this agent.

When two writers touch one file​

A run isn't the only thing that can change a collection. Another run, an action block, a person in the UI — any of them can edit a file while a run holds it.

The reconcile checks before it writes: does the collection still hold what this run was given? If it does, the write goes through. If it doesn't, nothing is written and the path is recorded. Deletes go through the same check, so a file the run removed and somebody else edited stays put.

That check catches a writer who already wrote. It can't catch one writing at the same moment, so the run also claims each path it saves, for as long as the save takes. Another run reaching a claimed path stands off instead of overwriting. Claims are per file: two runs sharing a collection while working on different files both save, and neither is refused.

Three outcomes end up in the workspace-outcomes collection, keyed by run:

  • conflict — two writers, one file. Carries three hashes: what the run was given, what the collection holds now, and what the run left. ours: null means the run deleted a file somebody else had edited.
  • contested — another run was writing the file at the same moment, so this one stood off. No hashes, because nothing has been written to disagree about yet. The row names the path so you can stop the two runs sharing it.
  • orphan — a file written outside every mounted collection, so nothing owns it.

A status item reports how many there were, so a run that ends with unsaved work doesn't end quietly.

Containment​

By default the run is confined to the workspace it was given, through two settings that answer different halves of the same question.

settingSources: [] stops the run reading its configuration out of the workspace. This one is easy to miss. A projected directory holds whatever your collections hold, and in a real application your users write those. A CLAUDE.md or a .claude/settings.json sitting among them is user input — and an agent reading its instructions out of user input is a different product than the one you shipped.

The sandbox settings stop the run writing outside the workspace. A working directory is not a fence: absolute paths still resolve from inside it. The default names the root as the only writable path and refuses commands that ask to run unsandboxed.

A third setting stops the run leaving the workspace. The SDK's worktree tools relocate a run mid-flight when the model asks for it, and a projection that filled one directory would then be reconciling a tree the run had already walked away from. Those tools are taken out of the run's reach.

Set settingSources or sandbox yourself and yours wins. The disallowed tools merge instead, so adding your own doesn't quietly give the relocation ones back. contain: false turns all three off, which is what you want when you control everything in the workspace and nothing else.

Tool approval​

By default the agent governs its own tools through the SDK's permissionMode. To gate a tool call from your flow, pass onToolApproval: it receives each request and returns allow, deny or held, and the decision surfaces as a status item.

const agent = claudeCodeAgent({
permissionMode: "default",
onToolApproval: async (req) => {
if (req.toolName === "Bash") return { decision: "deny", message: "no shell" };
return { decision: "allow" };
},
});

A deny tells the model, and the turn goes on. A held decision is for a call a person decides later: the call doesn't run, the turn stops, and the run records it as waiting on approval rather than denied. You rarely return it yourself. A harness manager built with approveTools: true hands you an onToolApproval that does, then waits for the person and resumes the session with their answer.

Calls the SDK already allows never reach onToolApproval. That covers every tool named in allowedTools, and whatever permissionMode approves on its own (acceptEdits approves file edits, bypassPermissions approves everything).

Choosing the model​

model takes a Claude model id, or a resolver asked once per run, like cwd and resume. A resolver is how one block runs a different model for each session: read the id from the session's state. A resumed conversation is handed the same id. Return null, or leave model unset, and Claude Code runs its own default.

claudeCodeAgent({
detached: true,
resume: (ctx) => previousSessionIdFor(ctx),
model: (ctx) => (ctx.session.state as { model?: string }).model ?? null,
});

To offer a person a choice, listClaudeModels asks Claude Code which models it offers, the same way probeClaudeAccount below asks about the credential: no message, no model turn.

import { listClaudeModels } from "@flow-state-dev/claude-code/sdk";

const list = await listClaudeModels({ env });
if (list.status === "models") {
const preselected = list.models.find((m) => m.isDefault) ?? list.models[0];
}

Each entry carries the concrete id an alias resolves to: the row Claude Code calls sonnet, or its default, comes back under the model it points at today. Store that id, and a session keeps its model after the alias moves on. A failed call resolves to { status: "failed", reason } and never throws.

Checking for a credential before you start​

A run with no credential fails on its first turn, which for a server can be long after it started. probeClaudeAccount asks Claude Code up front whether it has something to run on: an API key, or a sign-in under the home folder (the keychain on macOS). It sends no message, so it costs no model turn, and takes about half a second.

import { harnessEnv } from "@flow-state-dev/core";
import { probeClaudeAccount } from "@flow-state-dev/claude-code/sdk";

const env = harnessEnv({ pass: ["PATH", "HOME", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] });
const account = await probeClaudeAccount({ env });
if (account.status !== "account") throw new Error(`Claude Code can't run: ${account.reason}`);

Pass the same env you give claudeCodeAgent, so the probe sees the credential the run will see and no other. It resolves to one of:

statusWhenAlso carries
"account"Claude Code named where its credential comes fromapiKeySource and/or tokenSource, as the SDK reports them; a subscription sign-in names neither and comes back as email and subscriptionType
"none"No credential, no answer within timeoutMs (default 10 000 ms), or Claude Code couldn't be askedreason, a sentence you can show the user, and unanswered: true when Claude Code gave no answer
"sdk-missing"@anthropic-ai/claude-agent-sdk couldn't be loadedreason, with the load error

It never throws for any of these. It checks where a credential would come from, not that it works: a key that is set but rejected still reads as "account", and fails on the run's first turn.

Reading usage left​

A Claude subscription limits use per time window, such as five hours or a week, and a run fails once a window is used up. Claude Code reports where those limits stand while it runs, so the numbers come from runs. Pass onUsage and the block hands you each one:

import { harnessEnv } from "@flow-state-dev/core";
import { claudeCodeAgent } from "@flow-state-dev/claude-code/sdk";

const env = harnessEnv({ pass: ["PATH", "HOME", "CLAUDE_CODE_OAUTH_TOKEN"] });
const agent = claudeCodeAgent({
env,
onUsage: ({ window, observedAt }) => {
console.log(window.id, window.level, window.usedPercent, window.resetsAt && new Date(window.resetsAt));
},
});

Claude Code sends an update whenever its limits change, and at least once per run on a subscription. Each update becomes one usage window, for the limit that binds at that moment:

FieldValue
idClaude Code's name for the window: five_hour, seven_day, or a weekly variant such as seven_day_opus
minutes300 for five_hour, 10 080 for the seven_day family, null for any other id
level"ok" (under its warning line), "warning" (near the limit) or "reached"
usedPercent0 to 100, only when Claude Code sends one, which it does near a limit. Otherwise null, never 0
resetsAtWhen the window resets, in epoch ms

The report around it also carries harness ("claude-code", exported as CLAUDE_CODE_HARNESS), observedAt, runStartedAt, and accountId, the signed-in email or null.

A run on an API key reports nothing. onUsage is awaited, but a throw from it doesn't fail the run: a warning is logged and the run carries on. It works on in-session and detached blocks alike.

If you read the SDK stream yourself, usageWindowOf(info) maps one raw event payload to a window, or null.

To show who is signed in before any run, readClaudeUsage({ env }) is a usage reader. It never throws:

statusWhen
windowsSigned in, or running on an OAuth token. Carries plan and accountId, and no windows: those arrive from runs.
api-keyClaude Code runs on an API key, which has no subscription limit.
signed-outNobody is signed in.
unavailable@anthropic-ai/claude-agent-sdk isn't installed, or Claude Code couldn't be asked or didn't answer within timeoutMs (10 seconds by default) or before signal aborted.

Pass the same env as your runs. To keep run windows and readings together and serve them to a page or a worker, register readClaudeUsage as the claude-code reader of the harness manager's usage query and pass onUsage through from the manager's harness slot.

The numbers come only from Claude Code's run events and its account answer. Neither onUsage nor readClaudeUsage reads a credentials file or calls a usage endpoint.

Error handling​

SituationBehavior
@anthropic-ai/claude-agent-sdk not installedThrows ClaudeAgentSdkNotInstalledError with an install hint.
The agent finishes with an error result (hit max turns, budget, or a runtime error)Treated as an outcome: the handle's status is "errored", its outcome is stopped-at-limit or failed (with the SDK's own resultSubtype alongside), and an error item is emitted. No throw.
The turn ends on an API error (the SDK reports a success result with is_error set)Treated as a failure, not a success: status is "errored", outcome is failed, resultSubtype stays "success", and an error item carries the SDK's error text with code is_error. No throw.
The SDK throws mid-streamWrapped in ClaudeAgentRunError and rethrown after an error item.
The block's signal fires (a deadline, a cancel)The agent is told to stop. The block waits up to 5 seconds for its process to exit, so the session's transcript is complete before anything resumes it, then rethrows the signal's reason after an error item.
A tool or sub-agent is still open when the stream endsIts item is marked incomplete.
Empty promptValidation error before the agent starts.

Limitations​

This version runs the agent and observes it. It does not let flow-state-dev drive the SDK loop one step at a time, register flow-state-dev blocks as tools the agent can call, or map sub-agents to nested flow-state-dev generators. Tool approval degrades to the SDK's own mechanism until native human-in-the-loop lands. Prompts are strings, not streamed input.