npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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-code

The /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-sdk

Attach 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. A CLAUDE.md or a .claude/settings.json sitting among them is user input, and without this the run obeys it.
  • Sandbox settings — the run doesn't write outside the workspace. cwd is a working directory, not a fence: absolute paths still resolve, so the boundary has to be declared. The default names the root in filesystem.allowWrite and refuses allowUnsandboxedCommands, 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

Documentation

Coding agents · Claude Code SDK agent · Harness manager