@flow-state-dev/claude-code
v0.3.1
Published
Claude Code integration for flow-state-dev: run a Claude Code agent in-process as a block.
Maintainers
Readme
@flow-state-dev/claude-code
Claude Code integration for flow-state-dev. The /sdk entry runs a Claude Code agent
in-process and streams its work through the flow's item stream, backed by the optional
@anthropic-ai/claude-agent-sdk peer dependency.
It is a harness: a coding agent driven as a block, returning the neutral run handle declared
in @flow-state-dev/core. @flow-state-dev/codex and @flow-state-dev/cursor
are the other two, and @flow-state-dev/harness-manager drives any of them from
a task board.
The /cli entry exports defaultResolveClaudeCli and defaultClaudeCliExec, with their types, for
hosts that run the local claude binary themselves. The resolver returns the binary path, working
directory and environment, plus the function that runs the binary. Swap that function out to mock
the subprocess in tests.
Installation
pnpm add @flow-state-dev/claude-codeThe /sdk entry also needs an optional peer; see Quick start (SDK).
Quick start (SDK)
The /sdk entry runs a Claude Code agent in-process. Install the optional peer:
pnpm add @flow-state-dev/claude-code @anthropic-ai/claude-agent-sdkAttach the capability so a generator can hand work to the agent, or use the block directly as a sequencer step:
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()],
});The agent's messages, reasoning, tool calls, and sub-agents become flow-state-dev items as it runs, and its session persists across requests. See the Claude Code SDK agent guide for the full surface.
Working with SDK runs
Running as background work
The SDK agent keeps its own session state — sdkSessionId (the run it resumes)
and sdkAgentRuns (the handles it has returned). Pass detached: true to run it
as background work instead, which switches that off:
claudeCodeAgent({ detached: true });Nothing is declared, read, or written, and the SDK is handed no resume, so each
run starts fresh. Use it when the agent runs as a task-board worker in a child
session: rows may share that session, so the board refuses a dispatch whose block
declares session state. The run's own history is the child session's item stream
instead.
The returned handle still carries the SDK sessionId, and as a worker's output
it is persisted with the task — the option governs session state and resume, not
the result.
createClaudeCodeAgentCapability({ detached: true }) takes the same option, and you
have to pass it: the board refuses a block whose capability declares the schema, and
cannot see one a capability's preset adds at all.
Continuing a background run
To have the next job pick up the last one's conversation, pair two options:
claudeCodeAgent({
detached: true,
resume: (ctx) => lastSessionFor(ctx), // `null` or `""` starts fresh
onSession: (id, ctx) => recordSessionFor(ctx, id),
});onSession fires during the run, as soon as the agent names its session —
not from the returned handle, because a cancelled run returns none, and that is
the run you most want to continue. What it reports is the session the agent
confirmed it is in, which may not be the one you asked for: record what the hook
gives you, and treat "never fired" as "nothing to continue" so the next attempt
starts fresh instead of re-sending a session that is gone.
Both are background-path only. In session the block already resumes and records
the id itself, so passing either without detached: true throws at construction.
Cancelling a run
When the block's signal fires, the block tells the agent to stop, then waits for its process to exit before it rejects. The agent writes the session's transcript until it exits, so a run you cancel and resume straight away finds its conversation. The wait lasts at most 5 seconds; past that the block rejects anyway.
Stopping a run for a message
The block declares stopsOnAbort: true (core's HarnessBlock field): when its abort signal fires, the block returns only after its agent process has exited. The process gets 5 seconds to exit on its own; if it is still running then, the block kills it with SIGKILL and waits for it to exit. A process the agent started, such as a Bash tool command, can outlive that kill and keep writing to the working directory while the next step runs there. If the agent can start long-lived commands, build the manager with stopsOnAbort: false, so a steer waits for the run to end instead. A harness manager reads the field, so a line typed into a running Claude Code task stops the run and the same request continues its session with the line. A block rebuilt from it, through connectInput or a wrapping sequencer, doesn't carry the field; build the manager with stopsOnAbort: true to keep the stop for a wrapped block.
Asking before a tool call
onToolApproval is called with each tool call the SDK doesn't already allow, and answers { decision: "allow" }, { decision: "deny", message? } or { decision: "held", message? }. A deny tells the model and the turn goes on. A held call doesn't run, the turn stops (the SDK's interrupt), and the run records it as waiting on approval, not as denied: it is for a call a person decides outside the run. A harness manager built with approveTools: true hands one among its feeds, so ...feeds passes it on, and resumes the session with the person's answer.
A tool in allowedTools, and whatever permissionMode approves on its own (acceptEdits for file edits, bypassPermissions for everything), never reaches it. Under a manager with approvals on, name what a run may always do in allowedTools and use permissionMode: "default".
Giving a run its own working directory
By default a run works in whatever directory the server process is running in.
Pass cwd to point it somewhere else:
import { mkdir, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
const CHECKOUT_ROOT = "/var/agent-checkouts";
claudeCodeAgent({
// A fresh directory per run, created by the server. No caller input reaches
// the path.
cwd: async () => {
// `mkdtemp` creates the leaf, not the parent — ENOENT if the root is
// missing, which on a fresh machine it is.
await mkdir(CHECKOUT_ROOT, { recursive: true });
return mkdtemp(join(CHECKOUT_ROOT, "run-"));
},
// A fresh directory per run needs a fresh conversation to match.
detached: true,
});A function, not a string: one flow build serves many runs, so it resolves per invocation. It may return a promise, as here.
detached: true belongs in this example rather than beside it. By default the
agent hands the SDK a resume handle from the previous run in the same
session; combined with mkdtemp, the second invocation resumes a conversation
started in a directory that has nothing to do with the empty tree it now runs
in. Start fresh per run, as here, or keep a stable directory when you want
resume — a per-run directory with resume left on is the surprising combination.
The run's file tools address relative paths inside that directory, and so does
recordWork's record of what the run touched. It is a working directory, not a
boundary — a run can still reach an absolute path outside it, and that operation
is recorded at the path it reached.
Controlling what a run reads and what it runs in
Four more options travel alongside cwd:
// `checkoutForThisRun()` allocates a directory, so calling it in both resolvers
// would hand the run one directory and name a different one in its settings,
// with nothing throwing. Calling it once at build time is the other way to get
// it wrong: one flow build serves many runs, and they would all share the
// directory. So: once per invocation, keyed on the run's own context, which
// both resolvers are handed.
const checkouts = new WeakMap<object, Promise<string>>();
const checkoutFor = (ctx: object) => {
const existing = checkouts.get(ctx);
if (existing) return existing;
const fresh = checkoutForThisRun();
checkouts.set(ctx, fresh);
return fresh;
};
claudeCodeAgent({
cwd: (ctx) => checkoutFor(ctx),
// Which filesystem settings the run loads. Omitted, it loads all of them,
// exactly as the CLI does.
settingSources: ["user"],
// Settings for this run, above every source it loads (Claude Code's
// `settings.json` shape). Here: none of the loaded sources' hooks run.
settings: { disableAllHooks: true },
// The run's environment. Unset, the run inherits your server's whole
// `process.env`. Set, it REPLACES that environment: here, only the named
// variables reach the run (`harnessEnv` is from `@flow-state-dev/core`).
env: harnessEnv({ pass: ["PATH", "HOME", "ANTHROPIC_API_KEY"] }),
// The SDK's sandbox settings (`SandboxSettings`, an open object — the Agent
// SDK is an optional peer here, so its own type is not imported). A value or
// a resolver: the settings that confine a run name the directory it works
// in, and that is per run.
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 () => {} },
}),
// Capabilities installed on the block, same slot any other block takes.
uses: [myCapability],
// Runs after the block threw, with the error. It does not swallow it — the
// run still fails — so this is where anything the run was holding gets
// released. A capability cannot contribute lifecycle hooks, so this option
// is the only way to reach one.
onErrored: async (error, ctx) => { await releaseWhateverThisRunHeld(ctx); },
});settingSources is the one worth reading 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 — from resources your application's
users can write — then those files are user input, and the run reading
configuration out of them means your users configure your agent. Pass [] to
load none, or list only the sources you control.
A repository you trust: its conventions, with its hooks off. "project"
loads the repository's CLAUDE.md, its skills and its .claude/settings.json,
and that file can hold hooks: shell commands Claude Code runs by itself on events
like a prompt or a stop, which show in no transcript. A run can write one into the
file for the next run to execute. settings is a settings layer of your own, in
settings.json shape, that sits above every source the run loads, so
settings: { disableAllHooks: true } keeps the conventions and turns every hook
off:
claudeCodeAgent({
cwd,
settingSources: ["project"],
settings: { disableAllHooks: true },
env: harnessEnv({ pass: CLAUDE_CODE_PASS }),
});settings is forwarded as given and unset by default. With mcpTools set, the
run's settings also carry disableClaudeAiConnectors: true, whatever yours say.
env decides what the run can read. Leave it unset and the Claude Code
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 instead of adding to it, so
harnessEnv({ pass: [...] }) from @flow-state-dev/core is the way to hand the
run only what it needs. Nothing is passed unless it is named, PATH and HOME
included, and a named variable that is unset is left out.
CLAUDE_CODE_PASS is the list a run needs to work, if you'd rather not write
your own: PATH, HOME, USER, SHELL, TMPDIR, ANTHROPIC_API_KEY,
CLAUDE_CODE_OAUTH_TOKEN, and the proxy and CA settings a proxied network
needs, in upper and lower case. harnessEnv({ pass: CLAUDE_CODE_PASS })
builds the env; hand the same one to probeClaudeAccount, listClaudeModels
and readClaudeUsage so they see the run's account. To hand the run a key of your
own as well, add its name: harnessEnv({ pass: [...CLAUDE_CODE_PASS, "GH_TOKEN"] }).
A variable you pass is readable by every command the run starts, and the model can
print it.
Include lowercase proxy names too; curl reads only http_proxy.
mcpTools gives the run tools your process serves. Each entry becomes an MCP
server inside your process, named by its key, and the run calls its tools as
mcp__tracker__…. Each tool is a name, a description, a JSON Schema for its
input and a call function. The run never sees where a tool's work happens or
what credential it uses: the option can't carry an address or headers. Set it,
even to {}, and it is the run's whole MCP set: no servers from settings files
or your claude.ai connectors. The tools are allowed without asking; name one in
disallowedTools to keep it out. A resolver gets the run's context only, like
cwd. Each close is called when the run ends. Leave it unset and nothing
changes. If the run still reports a server it wasn't handed, its session says
so. A coding run on a Workforce project gets its project's servers this way,
through projectMcpTools from @flow-state-dev/workforce.
allowWrite is not a fence. The SDK documents it as additional paths to
allow writing, merged with the paths that Edit(...) permission rules already
grant — so listing your workspace there widens what the run may write, it does
not narrow it to that directory. What confines a run is enabled: true turning
sandboxing on at all, plus denyWrite and the permission rules; allowWrite is
how you punch your workspace through those. If you need a hard boundary, say so
with denyWrite and verify it against a real run rather than assuming this
option gives you one.
Nothing here changes by default: leave an option out and the run behaves exactly as it does today.
Reusing a directory across runs
A throwaway directory is the easy case. If you want runs that belong together to share a checkout — a retry picking up where the last attempt stopped, say — the path has to be derived from something stable, and that is where the sharp edges are:
import { createHash } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { isAbsolute, join, relative } from "node:path";
function segment(value: string | undefined): string {
// Absence is `0`; a present value is `1` plus a SHA-256 digest of its UTF-16
// code units — a fixed 65 characters, so a long id cannot overflow 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.
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; provisioning is the resolver's
job. Wire both halves of the identity in — the authenticated tenant is on
ctx.session.identity.tenantId, and ctx.session.identity.id stays the bare
session id two tenants can share — 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 into this directory, so it has to exist or the spawn
// fails with ENOENT. `recursive` makes reuse idempotent.
await mkdir(dir, { recursive: true });
return dir;
},
});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 one session get the same tree and race in it. Declare a
concurrency policy on the action that runs the agent — concurrency: "queue"
serializes on the session, which is the same value the checkout is derived from,
so one run finishes before the next starts ("reject" if a second request
should be dropped instead).
A policy arbitrates dispatches, which leaves two cases open. A generator's
tool calls in one model step run concurrently, so a block exposed as a
model-facing tool can be invoked twice inside a single dispatch, where the
policy never sees it. And by default the arbiter keeps its keys in the running
process, so external workers are arbitrated only when the queue adapter supplies
a lease backend they all share (WorkerAdapter.leaseBackend). The recipe is
safe for a single-instance host, or workers sharing a lease backend, running the
agent as a step, one invocation per run; give the agent a fresh directory per
run otherwise.
Encoding rather than validating is the whole point, and it is worth being
explicit about why. 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 whole list: distinct ids give distinct directories, and the output alphabet contains nothing any filesystem treats specially.
A digest rather than a reversible encoding, because the output has to be
bounded. Filenames stop at 255 characters, and anything that preserves its
input grows with it — hex of UTF-16 code units runs to four characters each, so
a 64-character session id produced a 257-character component and mkdir failed
ENAMETOOLONG. A digest is a fixed 65 characters for any input. The honest
trade is that distinctness now rests on SHA-256 rather than on arithmetic, and
the path no longer tells you whose checkout it is. Do not truncate instead —
trimming a reversible encoding to fit maps two long ids onto one segment, which
is the collision the derivation exists to prevent.
Hash the code units, not the UTF-8 bytes. UTF-8 cannot represent a lone
surrogate — a legal JS string that JSON will carry — so anything transcoding
through it maps "\ud800", "\ud801" and a literal "�" onto one 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 a stand-in — tenantId ?? "default" — but a stand-in
is a value some tenant may legitimately hold, and then an un-tenanted host and
that tenant address one directory. The tag does the same job without the
collision, and keeps every segment non-empty, which matters because join
discards an empty one.
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.
Prefer a key your own code assigned over one that arrived with the request.
Full behaviour, including what an empty or symlinked directory does, is on the
cwd option's own docs in src/sdk/agent.ts and in the
SDK agent guide.
Recording what a run did
recordWork: true records the run's file operations and its own to-do list as
state you can read afterwards. Off by default; on, the agent declares four
resource collections and writes into them as it goes:
| Accessor | One entry per |
|----------|---------------|
| observed-file-ops | path the run's file-writing/editing tools touched — lastKind, outcome (pending/applied/failed), lastTouchedAt, appliedCount. Paths, never contents |
| observed-plan | to-do item the conversation kept — title, status, previousStatus, lastOutcome, and requestId, the request that last changed it |
| observed-plan-list | conversation whose plan could not be written in full — status: "incomplete" and the reason, written only when a plan entry fails to write, and kept for the rest of the conversation; plus one pointer per run, whose conversation names the namespace the run's plan is in |
| observed-gaps | mutation the recorder understood and could not record — kind (file/plan/run) says which record it stands in for, plus the reason and the raw path |
The observed-plan shape and its observed-plan-list marker are defined in
@flow-state-dev/core and re-exported here, so either import gives the same
object. Their entries stream to an open page as they change. See
the run's plan.
claudeCodeAgent({ detached: true, recordWork: true });Leave systemPrompt out and the run starts from Claude Code's own system prompt,
the same one the claude CLI uses. To add instructions to it, use
systemPrompt: { type: "preset", preset: "claude_code", append: "…" }. A plain
string replaces it, and "" starts the run with no system prompt.
The plan half fills only when the agent uses Claude Code's to-do tools
(TaskCreate, TaskUpdate, TaskGet, TaskList). Every run is offered them:
on newer models Claude Code switches them off unless they are named in
allowedTools, so the block adds them to whatever list you pass. They ask no
permission, so naming them allows nothing else. A run with
permissionMode: "bypassPermissions" also gets Claude Code's search tools,
Grep and Glob, which it likewise offers only when named. In other modes
they're left out, because naming them would approve searches outside the
working directory without asking; name them yourself if you want that. To keep
a run from any of these, list them in disallowedTools. Neither the default prompt nor recordWork makes
the agent use them, and it often works without a list. An empty observed-plan means the
run kept no list; it is not a recording failure.
File-operation and gap entries are keyed as <requestId>/<invocation>, so a session
reused across runs — and a request that runs the agent more than once — both answer per
run. Plan entries are keyed by conversation, as conversation/<Claude Code session id>/<task id>,
so a later turn's TaskUpdate keeps the title its earlier TaskCreate wrote. Each run writes
its pointer under <requestId>/<invocation> once the session id is known; read a request's
plan through it with core's readPlanTarget.
All four declare client state reads, so
GET /sessions/:id/resources/observed-file-ops?topicPrefix=observed-file-ops/<requestId>/
returns them; each row's payload is on clientData. Follow nextCursor — the
route pages.
The request id in that prefix is percent-escaped into one key segment, so a
filter built from a raw id only matches when the id needs no escaping. Escape
% first, then /, \ and control characters, and .. as %2E%2E; every
other id — brackets, dots and ./... included — is used verbatim. An id with
no such characters, which is the common case, needs nothing.
appliedCount counts only the operations on that path the harness confirmed
applied, not the attempts: each one is recorded twice, once when the call is
seen and once when its result arrives. outcome beside it describes only the
last settlement, so the count is what says how many of a path's touches landed.
0 and null are different answers — 0 means the run touched the path and
nothing applied, null means the row was written before the field existed.
The file record covers tool-driven operations only. A run that edits through the shell makes no file-tool call, so nothing is recorded for it. Recording never fails the run: what it cannot handle becomes a gap row.
createClaudeCodeAgentCapability({ recordWork: true }) takes the same option and
needs it — the capability declares the collections itself, because a block in a
capability's tools contributes no resource declarations to the flow.
Pull requests it opened
The block records the pull requests a run opens in the session's pull-request
record, observed-pull-requests, defined in @flow-state-dev/core. A Bash call
that runs gh pr create, or a create_pull_request MCP tool (such as
mcp__github__create_pull_request), whose output holds the pull request's link is
one row, written as the call settles. On by default, apart from recordWork;
with recordPullRequests: false the block records none and declares no
collection. See
pull requests a session opened.
A run whose files are resources
cwd hands a run a directory. It doesn't put anything in it, and it doesn't
bring anything back. createWorkspaceAgentCapability does both:
import { createWorkspaceAgentCapability } from "@flow-state-dev/claude-code/sdk";
const workspace = createWorkspaceAgentCapability({
root: async () => mkdtemp(join(CHECKOUT_ROOT, "run-")),
});
generator({
name: "coder",
model: "openai/gpt-6-luna",
prompt: "Use the workspace agent to make the change.",
uses: [workspace],
});Every resource collection on the block's context is mounted at its pattern
prefix, so a collection matching artifacts/** appears at <root>/artifacts/.
Narrow it with collections or exclude if you want fewer.
After the run, what changed goes back to the collection it came from — unless something else changed the same file while the run held it. Then nothing is written and the path is recorded instead.
Reading what could not be saved
Three outcomes need a person: a conflict (two writers, one file), a
contested path (another run was writing it at the same moment, so this one
stood off), and an orphan (a file written outside every mounted
collection). All three land in the workspace-outcomes collection, keyed by
run:
const unsettled = await ctx.resources["workspace-outcomes"].list();A conflict carries three hashes — what the projection last wrote, what the
collection holds now, and what the run left — which is what lets you say who
changed what. ours: null means the run deleted a file somebody else had
edited. A contested path carries no hashes: nothing has been written yet, only
a claim held elsewhere. Claims are per file, so two runs working on different
files in one collection never contend.
A status item says 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. Two settings, answering different halves of the same question:
settingSources: []— the run doesn't read its configuration out of the workspace. A projected directory holds whatever the mounted collections hold, and in a real application those are written by its users. ACLAUDE.mdor a.claude/settings.jsonsitting among them is user input, and without this the run obeys it.- Sandbox settings — the run doesn't write outside the workspace.
cwdis a working directory, not a fence: absolute paths still resolve, so the boundary has to be declared. The default names the root infilesystem.allowWriteand refusesallowUnsandboxedCommands, which is the escape a command can otherwise ask for by itself. disallowedTools— the run doesn't leave the workspace. The SDK's worktree tools relocate a run mid-flight when the model asks, and a projection that hydrated one directory would then be reconciling a tree the run had already left.
Set settingSources or sandbox yourself and yours wins — containment is a
default, not a lock. disallowedTools merges instead, so adding your own
doesn't silently take the relocation ones away. contain: false turns all
three off, which is what a trusted-workspace deployment wants and what nothing
else should.
Choosing the model
model takes a Claude model id, or a resolver asked once per run like cwd and
resume, so one block can run a different model per session; a resumed
conversation is handed it too. null leaves Claude Code on its default.
listClaudeModels({ env }) asks Claude Code which models it offers, with no
message and no model turn, and resolves to { status: "models", models } or
{ status: "failed", reason }. Each model carries the concrete id its alias
resolves to (sonnet, the default row), its name, and whether it is the default,
so a stored id keeps naming one model after the alias moves on.
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:
| status | When | Also carries |
|----------|------|--------------|
| "account" | Claude Code named where its credential comes from | apiKeySource 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 asked | reason, a sentence you can show the user |
| "sdk-missing" | @anthropic-ai/claude-agent-sdk couldn't be loaded | reason, 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.
checkClaudeCodeReadiness(options) takes the same options and answers as core's HarnessReadiness, for harness-manager's harnessReadiness. It asks once more when the first probe got no answer, since a cold start can miss the window. It answers { ready: true }, or { ready: false, reason, remedy }: the remedy is CLAUDE_SIGN_IN_REMEDY ("Sign in with claude, or set ANTHROPIC_API_KEY") when there is no credential, "Install @anthropic-ai/claude-agent-sdk" when the SDK is missing. When neither probe got an answer it is unanswered: true with no remedy. It never throws.
Reading usage left
On a subscription, Claude Code reports its limit windows while it runs. claudeCodeAgent({ onUsage }) hands each one to your HarnessUsageHook as a HarnessUsageReport (harness: "claude-code", the window, observedAt, runStartedAt, accountId), at least once per run. The window names the limit that binds (five_hour is 300 minutes, the seven_day family 10 080; other ids have minutes: null), its resetsAt and a level (ok, warning, reached). usedPercent is set only near a limit and is otherwise null. An API-key run reports nothing. A throw from onUsage is logged and the run carries on. It works on in-session blocks too.
readClaudeUsage({ env, timeoutMs?, signal? }) is a HarnessUsageReader for who is signed in: windows with plan, accountId and no windows (those come from runs), api-key, signed-out, or unavailable when the SDK isn't installed or Claude Code didn't answer (which keeps a stored reading). It never throws. usageWindowOf(info) maps one raw rate_limit_event payload to a window, and CLAUDE_CODE_HARNESS is "claude-code". Nothing here reads a credentials file, calls a usage endpoint, or uses the SDK's experimental usage call. Guide: reading usage left.
What a run spent
claudeCodeAgent reports its spend to the request with reportHarnessSpend as the run goes, under one runId for the run, each report replacing the last: at Claude Code's init message, with the model it names and null tokens; after each assistant message, with the tokens so far (each model message's usage counted once, though the SDK repeats it on every assistant message that shares its id); and when the run ends, however it ends, with the whole query's count from Claude Code's result, which replaces the running sum. Each report carries harness: "claude-code", the total_cost_usd it reported as costUsd, and billing from Claude Code's own accountInfo() ("api-key" when it runs on a key, "subscription" when it is signed in to a plan, otherwise null). A run that failed after its result arrived reports that result's numbers. A run that ended before any usage arrived reports null tokens and cost. Guide: what a run spent.
Following what a run does
claudeCodeAgent({ onActivity }) reports what a run is doing: each completed assistant message as { kind: "message", text } and each tool call the run starts as { kind: "tool", name, arguments }, with arguments the call's JSON text. The hook isn't awaited, and a throw from it is logged while the run carries on.
Running tests
pnpm --filter @flow-state-dev/claude-code test