# Claude Agent Sdk Worker

> Building a headless or containerized worker that drives the Claude Agent SDK (@anthropic-ai/claude-agent-sdk, the TypeScript/npm SDK that wraps the Claude Code CLI, not the `claude` CLI directly). Use when a query() session hangs and never finishes, when running Claude Code unattended with no human to answer a tool-permission prompt, when streaming input into a live session for mid-run steering, when the SDK's systemPrompt / SDKUserMessage / hook shapes do not match what you wrote, or when building a one-shot vs long-lived agent worker. Covers the streaming-input session that never ends (break on the result message), permissionMode bypassPermissions, the preset-append systemPrompt, SDKUserMessage.parent_tool_use_id, query()/interrupt() teardown, PreToolUse/PostToolUse hook fields, subscription-token billing, and version drift.

- Skill: `blakehastings/claude-agent-sdk-worker` (Agent Skill)
- Install (CLI): `npx skillmds@latest add blakehastings/claude-agent-sdk-worker`
- Raw SKILL.md: https://api.skillmd.com/api/skills/blakehastings/claude-agent-sdk-worker/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: BlakeHastings (https://skillmd.com/u/blakehastings)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/blakehastings/claude-agent-sdk-worker

---


# Claude Agent SDK: headless worker gotchas

Building an autonomous worker around `@anthropic-ai/claude-agent-sdk` (the npm SDK; it spawns and speaks
stream-json to the bundled `claude` CLI, e.g. `node_modules/@anthropic-ai/claude-agent-sdk-*/claude ...`). These
each cost a debugging session.

**Version drift is real; read the installed `.d.ts`.** The API observed here is 0.3.x (0.3.200). A stale pin
(`^0.1.0`) installs a genuinely different API and its types won't match — `appendSystemPrompt` existed in the
mental model but not in 0.3.x, for instance. Before writing against it, read `node_modules/@anthropic-ai/
claude-agent-sdk/sdk.d.ts` (or `unpkg.com/@anthropic-ai/claude-agent-sdk@<ver>/sdk.d.ts`) for the real
`Options`, `SDKMessage`, and hook types rather than guessing.

## The streaming-input session never ends on its own (the hang)

If you pass an **async generator** as `prompt` (streaming input, so you can inject more user messages mid-run),
the session stays alive *waiting for more input* after each turn. The SDK emits an `SDKResultMessage`
(`type: "result"`) at the end of **each turn**. So this loop hangs forever — it gets the result but then blocks
on the next iteration that never comes:

    for await (const message of query({ prompt: gen, options })) {
      if (message.type === "result") result = message.result;   // WRONG: keeps looping
    }

Break on the first `result` — the turn's result *is* the job's result — then tear the session down:

    for await (const message of session) {
      if (message.type === "result") { result = message.result ?? result; break; }
      else if (message.type === "assistant") { /* fallback text */ }
    }
    // teardown: close the input generator AND interrupt, or Claude Code lingers
    inputQueue.end();
    await session.interrupt?.().catch(() => {});

`session` (the `Query` returned by `query()`) extends `AsyncGenerator<SDKMessage>` and has `interrupt(): Promise
<void>`. **`interrupt()` alone does not end the session** while the input is still open — it stops the current
turn; you must also close the input generator so the CLI subprocess exits. A job that "never completes" plus
leftover `claude` processes is this bug: because the loop never breaks, your cleanup `finally` never runs, so the
input is never closed.

If you only need one turn and no mid-run steering, the simplest fix is to *not* keep the input open — yield the
task and let the generator return, so the session ends and the `for await` completes naturally.

## permissionMode: run unattended

Set `permissionMode: "bypassPermissions"` in `options` for an autonomous run. In the default mode Claude Code
pauses for a tool-use permission prompt that nothing answers, and the job hangs with no error. `allowedTools`
lists which tools are permitted but is not a substitute — actions outside it (or certain operations) still
prompt. `bypassPermissions` runs all tools without checks, which is appropriate when the container is the
sandbox. (Refuses to enable in some setups, e.g. running as root — run the worker as a non-root user.)

## systemPrompt: append to the preset, don't replace it

To keep Claude Code's own coding system prompt *and* add your framing, use the preset-append object; a bare
string **replaces** the preset (losing the coding brain):

    systemPrompt: { type: "preset", preset: "claude_code", append: instructions }

There is no top-level `appendSystemPrompt` option in 0.3.x.

## SDKUserMessage needs `parent_tool_use_id`

Messages yielded into a streaming-input generator are `SDKUserMessage` and require `parent_tool_use_id` (null for
a normal user turn) — omitting it is a type error:

    { type: "user", message: { role: "user", content }, parent_tool_use_id: null }

## Hooks for per-tool telemetry

`options.hooks.PreToolUse` / `PostToolUse` fire in-process. The hook input carries `tool_name`, `tool_input`,
and `tool_use_id` (PostToolUse also `tool_response`). Open a span on `PreToolUse`, end it on `PostToolUse` keyed
by `tool_use_id`. Claude Code has **no native OpenTelemetry**, so this hook path is how you emit per-tool spans.

## Billing and the CLI binary

The SDK drives the bundled `claude` CLI, so the CLI's auth applies: `CLAUDE_CODE_OAUTH_TOKEN` (from `claude
setup-token`, a subscription token) bills the subscription; a present `ANTHROPIC_API_KEY` takes precedence and
bills API credits instead. For subscription billing, keep the API key out of the process env entirely (in a
container image, never bake it in).

## Debugging a hung session

Log every message type as it arrives — it makes the failure mode obvious:

    console.info(`session message: ${message.type}${message.subtype ? ` (${message.subtype})` : ""}`);
    // system (init) -> assistant -> user (tool result) -> assistant -> result (success)

- You reach `result (success)` but the job never finishes -> the break/teardown bug above.
- You never reach `result` (stuck after `assistant`/`system`) -> a permission stall (set `bypassPermissions`) or
  a tool waiting on something.

