Claude Agent SDK — Implementation Reference
Research compiled for slices 4 (bootstrap) and 5 (capture). Read this before implementing anything that touches @anthropic-ai/claude-agent-sdk. Cross-references to the OpenAlmanac GUI's production implementation for anything ambiguous.
1. Package + setup#
Package: @anthropic-ai/claude-agent-sdk
Current stable: ^0.2.101 (as of April 2026) — pin exactly or use caret range
Node.js: 18+ minimum (we target 20+ already)
Format: ESM-only, works with tsup --format esm
No native/binary deps — safe to bundle, but mark as external in tsup.config.ts anyway to avoid bundling the SDK itself:
// tsup.config.ts additions for slice 4
external: [
"@anthropic-ai/claude-agent-sdk",
"@anthropic-ai/sdk",
"@modelcontextprotocol/sdk",
]Peer deps:
@anthropic-ai/sdk^0.81.0 (types used directly)@modelcontextprotocol/sdk^1.29.0 (for MCP; we won't use MCP in slices 4/5 but the SDK requires it)
2. Authentication — important#
Headless CLI auth reads ANTHROPIC_API_KEY from env. The SDK does NOT pick up Claude Code's local auth store (~/.claude/auth.json) reliably in headless mode.
Required behavior for almanac bootstrap / almanac capture:
- Check
process.env.ANTHROPIC_API_KEYbefore invokingquery() - If missing, exit with a clear error and link to how to set it:
error: ANTHROPIC_API_KEY is required for almanac capture. export ANTHROPIC_API_KEY=sk-ant-... - Do NOT attempt to auto-authenticate or open a browser
The SDK will throw mid-stream on auth failure, which is a poor UX. Gate upfront instead.
Correction to earlier plan docs: the slice 4/5 plans said "auth comes from Claude Code's existing auth store" — that was wrong. Update the plans accordingly.
3. The query() API#
import { query } from "@anthropic-ai/claude-agent-sdk";
const q = query({
prompt: string | AsyncIterable<SDKUserMessage>, // single string = one-shot; AsyncIterable = multi-turn
options: {
model?: string; // FULL name: "claude-sonnet-4-6", "claude-opus-4-6"
systemPrompt?: string | { type: "preset"; preset: string; append?: string };
allowedTools?: string[]; // "Read", "Write", "Agent", "mcp__server__tool"
disallowedTools?: string[];
agents?: Record<string, AgentDefinition>; // subagent configs
maxTurns?: number; // default ~100
maxCostUsd?: number; // hard stop on cost
cwd?: string; // working directory for tools
mcpServers?: Record<string, McpServerConfig>; // unused in slices 4/5
resume?: string; // resume by session_id
includePartialMessages?: boolean; // REQUIRED for streaming text deltas — default false
permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk" | "auto";
},
});
// q is an AsyncGenerator<SDKMessage>
for await (const msg of q) { ... }Gotchas:
includePartialMessages: trueis needed to getstream_eventmessages with text deltas. Without it, no streaming text — just completed assistant messages.- Model name uses full form in
options.model(claude-sonnet-4-6) but subagentAgentDefinition.modelaccepts short form (sonnet). Easy to flip. maxTurnsis a hard stop — no graceful wrap-up turn. Set generously (100+).
4. AgentDefinition#
interface AgentDefinition {
description: string; // short explainer (shown in UI/traces)
prompt: string; // system prompt for the subagent
tools?: string[]; // tool allowlist — subset of parent's tools
model?: string; // optional override: "sonnet" | "opus" | "haiku" (short form)
maxTurns?: number; // optional per-subagent turn cap
}Tool name format:
- Built-ins:
"Read","Write","Edit","Glob","Grep","Bash","WebSearch","WebFetch","Agent" - MCP:
"mcp__<server>__<tool>"
Tool scoping is enforced by the SDK. If a subagent is declared with tools: ["Read", "Grep"], the SDK denies any Write/Edit attempts. This is how we enforce "reviewer is read-only."
5. Message types in the stream#
type SDKMessage =
| { type: "assistant"; message: { content: ContentBlock[]; session_id?: string } }
| { type: "stream_event"; event: { type: "content_block_delta"; delta: { type: "text_delta"; text: string } } }
| { type: "user"; message: { content: ToolResultBlock[] } }
| { type: "tool_use_summary"; summary: string }
| { type: "result"; session_id?: string; subtype: "success" | "error"; result: string; total_cost_usd: number; num_turns: number }
| { type: "system"; subtype: "status"; status: string };
interface ContentBlock {
type: "text";
text: string;
} | {
type: "tool_use";
id: string; // unique tool call ID
name: string; // "Read", "Agent", "mcp__..."
input: Record<string, unknown> | string; // CAN be either — check before access
}
interface ToolResultBlock {
type: "tool_result";
tool_use_id: string;
content: string | unknown;
is_error?: boolean;
}Key invariants:
resultmessage is emitted exactly once at end — carriestotal_cost_usdandnum_turnssession_idappears first on anassistantmessage or onresult— capture it once- Tool results come through as a
usermessage (yes, counterintuitive) withtool_resultcontent blocks tool_use.inputcan be string-encoded JSON OR an object — always type-check before access
6. Subagent invocation — how it actually works#
Writer invokes reviewer via the Agent tool. SDK routes internally:
Writer assistant message:
content: [{
type: "tool_use",
name: "Agent",
id: "tool_abc",
input: {
subagent_type: "reviewer",
description: "Review my draft on JWT decisions",
prompt: "Read pages/jwt-vs-sessions.md and critique..."
}
}]
[SDK spawns reviewer with its AgentDefinition, cwd, restricted tools]
User message (back to writer):
content: [{
type: "tool_result",
tool_use_id: "tool_abc",
content: "<reviewer's full text output>"
}]
Writer continues, reads the feedback, decides what to do.
Crucial properties:
- Subagent gets a fresh session context — no shared conversation history with parent
- Subagent sees the same
cwdas parent (filesystem is shared) - Tool restrictions on subagent are enforced by SDK — reviewer can't Write even if its prompt tries
- Subagents cannot invoke other subagents (one level deep)
- Agent tool result is a string (the subagent's textual output) — parent must re-Read any files the subagent modified
7. Streaming output format#
Target UX:
[bootstrap] reading package.json
[bootstrap] reading CLAUDE.md
[bootstrap] identified anchors: Next.js, Supabase, Stripe
[bootstrap] writing .almanac/pages/nextjs.md
[bootstrap] writing .almanac/pages/supabase.md
[done] 3 pages, cost: $0.02, turns: 14
Pseudo-code for the formatter:
let currentAgent = "bootstrap"; // or "writer" / "reviewer"
for await (const msg of q) {
if (msg.type === "assistant") {
for (const block of msg.message.content) {
if (block.type === "tool_use") {
if (block.name === "Agent") {
const input = typeof block.input === "string" ? JSON.parse(block.input) : block.input;
currentAgent = input.subagent_type ?? "subagent";
console.log(`\n[${currentAgent}] starting`);
} else {
console.log(`[${currentAgent}] ${formatTool(block.name, block.input)}`);
}
}
}
} else if (msg.type === "result") {
const status = msg.subtype === "success" ? "done" : "failed";
console.log(`[${status}] cost: $${msg.total_cost_usd.toFixed(3)}, turns: ${msg.num_turns}`);
}
}formatTool(name, input) should produce something like reading src/auth/jwt.ts or writing .almanac/pages/auth.md. Keep it one line per tool use.
Full raw stream goes to .almanac/.capture-<session>.log.
--quiet mode: suppress all stream output; only print the final [done] line.
8. Cost + usage#
- Only
resultmessage carries cost (total_cost_usd: numberin USD) - No per-turn cost breakdown
- No token breakdown exposed (input/output/cache) — just USD
- Accumulate by reading the final
result; no need to sum anything
9. Errors#
query() throws on:
- Auth missing/invalid — before stream starts (check upfront, don't rely on this)
- Network error — mid-stream as a thrown exception
- Invalid options — malformed
agents, unknown tool name - Model not found
- Rate limit — after SDK's internal retries, surfaces as thrown error
Wrap the for await in a try/catch. Exit 1 with the error message.
Subagent errors: do NOT kill the parent. They surface as tool results with is_error: true. The writer can decide to retry or give up.
Interrupt/cancel: q.interrupt() returns a promise that waits for cleanup. Useful for SIGINT handling in the CLI.
10. Minimal slice 4 example#
// src/agent/sdk.ts
import { query, type AgentDefinition } from "@anthropic-ai/claude-agent-sdk";
export interface RunAgentOptions {
systemPrompt: string;
prompt: string;
allowedTools: string[];
agents?: Record<string, AgentDefinition>;
cwd: string;
model?: string;
onMessage?: (msg: unknown) => void;
}
export async function runAgent(opts: RunAgentOptions): Promise<{ cost: number; turns: number; result: string }> {
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error("ANTHROPIC_API_KEY is required");
}
const q = query({
prompt: opts.prompt,
options: {
systemPrompt: opts.systemPrompt,
allowedTools: opts.allowedTools,
agents: opts.agents ?? {},
cwd: opts.cwd,
model: opts.model ?? "claude-sonnet-4-6",
maxTurns: 100,
includePartialMessages: true,
},
});
let cost = 0;
let turns = 0;
let result = "";
for await (const msg of q) {
opts.onMessage?.(msg);
if (msg.type === "result") {
cost = msg.total_cost_usd ?? 0;
turns = msg.num_turns ?? 0;
result = msg.result ?? "";
}
}
return { cost, turns, result };
}11. Slice 5 subagent definition#
// src/agent/subagents.ts
import type { AgentDefinition } from "@anthropic-ai/claude-agent-sdk";
export function buildReviewerSubagent(reviewerPrompt: string): AgentDefinition {
return {
description:
"Reviews proposed wiki changes against the full knowledge base for " +
"cohesion, duplication, missing links, notability, and writing conventions.",
prompt: reviewerPrompt,
tools: ["Read", "Grep", "Glob", "Bash"], // no Write/Edit/Agent — read-only
// model: "sonnet", // default; only override if needed
};
}12. Pitfalls the docs don't emphasize#
includePartialMessages: trueis required for streaming text deltas. Without it, no progress visibility during long turns.tool_use.inputtype varies — sometimes string (JSON-encoded), sometimes object. Always type-check before access.- Model naming inconsistency —
options.modeluses full name (claude-sonnet-4-6),AgentDefinition.modeluses short (sonnet). Flip-prone. - Session ID capture timing — appears on first
assistantor finalresult. Capture on whichever comes first, don't assume one. maxTurnsis a hard stop. No wrap-up. Set to 100 for bootstrap, 150 for capture.- Subagents inherit
cwdand filesystem. Tool restrictions are the only boundary between writer and reviewer — no sandboxing. - Agent tool result is a string. If subagent wrote files, parent must re-Read them. The tool result is the subagent's text output, not file contents.
- Some SDK versions' types are imprecise — expect to cast or narrow message types with
if (msg.type === "X")gates. - Interrupting cleanly: use
q.interrupt()in a SIGINT handler. Don't justprocess.exit(0)mid-stream. - Resume:
resume: "sess_..."requires a valid session_id from a prior run. Capture + persist if you want to support resume.
13. GUI reference paths (canonical implementation)#
/Users/rohan/Desktop/Projects/openalmanac/gui/process-manager.js—_startProcess()+_iterateProcess()show the full lifecycle/Users/rohan/Desktop/Projects/openalmanac/gui/main/conversation.js— multi-turn session pattern/Users/rohan/Desktop/Projects/openalmanac/gui/main/agent-definitions.js—AgentDefinitionshape examples/Users/rohan/Desktop/Projects/openalmanac/gui/main/normalize-messages.js— SDK → app-level message conversion/Users/rohan/Desktop/Projects/openalmanac/gui/main/async-channel.js—AsyncIterablepattern for persistent multi-turn (slice 5 doesn't need this — one-shot capture)
14. What to copy vs. what to ignore from the GUI#
Copy:
query()+ async generator iteration- Message normalization switch statement
- Deferred finalization (capture the
resultmessage, but don't transition state until the generator is fully exhausted — some SDK versions emit additional messages afterresult) - Session ID capture on first assistant message
Ignore:
- ProcessManager concurrency control (we're CLI one-shot)
- IPC messaging (Electron-specific)
permissionModeswitching (for interactive use)- Slug-locking, artifact tracking, persistence (overkill for our CLI)
canUseToolcallback (custom tool validation — we usetoolsallowlist instead)createAsyncChannel()for persistent multi-turn — slice 4 and 5 are both one-shot
Implementation checklist#
-
npm install @anthropic-ai/claude-agent-sdk@^0.2.101 - Add
@anthropic-ai/claude-agent-sdk,@anthropic-ai/sdk,@modelcontextprotocol/sdktotsup.config.tsexternals -
src/agent/sdk.tswrapper (see section 10) -
src/agent/prompts.tsloader (bundled prompts inprompts/) - Auth gate: fail fast if
ANTHROPIC_API_KEYmissing - Streaming formatter (one-line-per-tool-use, track
currentAgentfor subagent delegation) -
--quietmode: suppress stream, show final cost line only - Capture
result.total_cost_usd+num_turns+session_id - Full transcript to
.almanac/.capture-<session>.log -
try/catcharound thefor awaitloop, clean exit code - Slice 5 adds
agents: { reviewer }to options +AgenttoallowedTools