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.
This is the companion to Claude Code remote dispatch. That page hands a task to a cloud session and returns a handle. This page runs the agent locally, in your process, and streams everything it does.
It is one of two harnesses flow-state-dev ships. Coding agents
covers what they have in common — the handle they both return, and the
configuration contract underneath cwd, resume and onSession. Codex
is the other one. This page is the Claude Code half.
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-5.4-mini",
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 { claudeCodeAgent } from "@flow-state-dev/claude-code/sdk";
const agent = claudeCodeAgent({
systemPrompt: "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 output | flow-state-dev item |
|---|---|
| Assistant text | message (streamed token by token when partial messages are on) |
| Extended thinking | reasoning |
| Tool call and its result | tool_output, with the tool call's id, name, and arguments |
Sub-agent (Task/Agent) | a container grouping the sub-agent's items |
| System init, history compaction | transient status |
| Errors | error |
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 child session, outside 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 child 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 child session 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 hand-off 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 the child session 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:
| How | Covered? |
|---|---|
| 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. | No. The arbiter is a map in the running process, so routing execution to external workers leaves both able to land in one checkout. That needs a lock in shared storage. |
So the recipe is safe for a single-instance host 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.
// 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"],
env: { ...process.env, CI: "1" },
sandbox: async (ctx) => ({
enabled: true,
filesystem: { allowWrite: [await checkoutFor(ctx)] },
}),
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.
env sets the run's environment variables. It replaces the process
environment rather than adding to it, so spread process.env when you meant to
add.
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.
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 and tools 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.
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 three 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:
| Collection | One entry per |
|---|---|
observed-file-ops | path the run's file-writing and file-editing tools touched: how it was touched, when, and whether the attempt applied |
observed-plan | item on the run's own to-do list: its wording, its current status, and the status before that |
observed-gaps | thing the recorder understood and could not record, with the reason and which record it stands in for |
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.
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 reads the same way, from observed-plan. A to-do item's status
is the harness's own word for it, and stays empty until the harness reports one.
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-5.4-mini",
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: nullmeans 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 or deny, 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" };
},
});
This is the interim story. Routing approvals through flow-state-dev's own
human-in-the-loop suspend and resume is a follow-up; onToolApproval is the seam
that work will plug into.
Error handling
| Situation | Behavior |
|---|---|
@anthropic-ai/claude-agent-sdk not installed | Throws 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 SDK throws mid-stream | Wrapped in ClaudeAgentRunError and rethrown after an error item. |
| A tool or sub-agent is still open when the stream ends | Its item is marked incomplete. |
| Empty prompt | Validation 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.
Related
- Coding agents — the harness contract, and the handle this block returns
- Codex SDK agent — the other harness, same handle
- Claude Code remote dispatch — the fire-and-forget cloud alternative
- Harness manager — driving this block from a task board
- Workspace projection — the package underneath the workspace capability
- Tools overview