# Claude Code Integration as a Local Adapter

> How to build a real-time API on top of Claude Code to power a custom chat interface.

This document reverse-engineers how **t3code** communicates with Claude Code and distills a replicable architecture for any project that wants to provide a browser-based (or mobile) chat interface backed by the Claude Code agent.

---

## Table of Contents

1. [Why This Approach](#1-why-this-approach)
2. [The Core SDK: `@anthropic-ai/claude-agent-sdk`](#2-the-core-sdk)
3. [Architecture Overview](#3-architecture-overview)
4. [SDK API Reference](#4-sdk-api-reference)
5. [Building the Server Adapter](#5-building-the-server-adapter)
6. [Building the Real-Time Transport Layer](#6-building-the-real-time-transport-layer)
7. [Session Lifecycle](#7-session-lifecycle)
8. [Streaming Messages to the Client](#8-streaming-messages-to-the-client)
9. [Handling User Input (Prompts, Approvals, Interrupts)](#9-handling-user-input)
10. [Tool Approval Flow](#10-tool-approval-flow)
11. [Resume & Persistence](#11-resume--persistence)
12. [Complete Data Flow Diagram](#12-complete-data-flow-diagram)
13. [Minimal Working Implementation](#13-minimal-working-implementation)
14. [Advanced Features](#14-advanced-features)
15. [Gotchas & Production Considerations](#15-gotchas--production-considerations)

---

## 1. Why This Approach

Anthropic's Claude Code agent (the `claude` CLI) is the runtime that performs agentic tasks: reading files, writing code, running shell commands, managing context, etc. The **only officially supported programmatic interface** to this runtime is the `@anthropic-ai/claude-agent-sdk` npm package. It wraps the CLI binary, spawns it as a child process internally, and exposes a clean async-iterable streaming API.

You **cannot** replicate Claude Code's agent behavior by calling the Anthropic Messages API directly — the agent has its own tool definitions, permission system, context management, session persistence, and hook infrastructure that the raw API does not provide.

**The pattern (exactly what t3code does):** Your app runs a local server that uses the SDK to drive the user's own `claude` CLI, and exposes a WebSocket (or SSE) API that your frontend connects to. The user's own subscription and auth are used — your app pays for nothing.

```
Browser UI  <──WebSocket──>  Your Local Server  <──SDK──>  User's own `claude` CLI
                                                            (user's auth & subscription)
```

---

## 2. The Core SDK

### Installation

```bash
npm install @anthropic-ai/claude-agent-sdk
# or
bun add @anthropic-ai/claude-agent-sdk
```

**Prerequisites:**
- **Node.js >= 20** recommended (>= 18 minimum, but 20+ needed for `AsyncDisposable` / `startup()` support)
- The SDK has peer dependencies on `zod ^4.0.0`
- Transitive dependencies: `@anthropic-ai/sdk ^0.81.0`, `@modelcontextprotocol/sdk ^1.29.0`
- **Your users must have `claude` CLI installed and authenticated** on their machines (this is the whole point)

### What It Does Internally

The SDK exports a single primary function: `query()`. When called, it:

1. **Spawns a Claude Code binary as a child subprocess** — it does NOT call the Anthropic API from your Node.js process directly
2. Communicates with the subprocess over stdin/stdout using NDJSON (newline-delimited JSON)
3. The subprocess handles ALL API calls, tool execution, context management, MCP servers, and permissions
4. Returns a `Query` object (extends `AsyncGenerator<SDKMessage>`) that streams every event from the agent
5. Accepts prompts as an `AsyncIterable<SDKUserMessage>` for multi-turn conversations
6. Exposes control methods: `interrupt()`, `setModel()`, `return(undefined)` (stop), etc.

```
Your Node.js process
  └── @anthropic-ai/claude-agent-sdk (thin wrapper)
        └── spawns: user's own `claude` CLI (subprocess)
              ├── stdin  ← SDK writes JSON prompts
              ├── stdout → subprocess streams NDJSON responses
              └── subprocess uses the user's own auth & subscription
                  to call Anthropic API, execute tools, manage context, etc.
```

### How t3code Uses the User's Own `claude` CLI (The Approach You Want)

The SDK can use two different binaries:
1. **Bundled binary** (default since v0.2.113) — a Claude Code binary shipped as an optional npm dependency. This requires its own auth (API key).
2. **User's installed `claude` CLI** — pointed to via `pathToClaudeCodeExecutable`. This uses whatever auth the user already set up.

**t3code uses approach #2.** It sets `pathToClaudeCodeExecutable` to the user's own installed `claude` CLI (configurable, defaults to `"claude"` resolved from PATH) and passes `env: process.env` to inherit the user's environment. This means:

- The user's **own Claude subscription** is used (Max, Pro, Team, API key — whatever they have)
- The user's **own auth session** is used (they already ran `claude auth login`)
- The user's **own settings, CLAUDE.md files, and MCP servers** are available
- Your app pays for **zero API tokens** — it all runs on the user's account
- Your app is just a **UI wrapper** around the user's own Claude Code installation

This is exactly how the VS Code extension works — it's a UI that drives the user's own `claude` CLI via the Agent SDK.

```typescript
// This is the t3code pattern — use the user's own CLI
const runtime = query({
  prompt: promptQueue,
  options: {
    pathToClaudeCodeExecutable: userConfiguredPath || "claude", // user's own CLI
    env: process.env,  // inherit user's auth, API keys, everything
    cwd: projectPath,
    // ...
  },
});
```

### Pre-warming with `startup()`

Since v0.2.89, the SDK provides a `startup()` function that pre-warms a subprocess. It returns a `WarmQuery` object — you must use `warmQuery.query()` instead of the standalone `query()`:

```typescript
import { startup, query } from "@anthropic-ai/claude-agent-sdk";

// Pre-warm at server boot — returns a WarmQuery (NOT fire-and-forget)
const warm = await startup({ options: { pathToClaudeCodeExecutable: "claude", env: process.env } });

// Use warmQuery.query() for the first session (~20x faster)
const firstSession = warm.query(promptQueue);

// Subsequent sessions use the standalone query()
const nextSession = query({ prompt: promptQueue2, options: { ... } });

// If you don't need the warm query, close it explicitly
// warm.close();
```

> **Note:** `WarmQuery` extends `AsyncDisposable` (requires Node.js >= 20). If you don't need pre-warming, just use `query()` directly — it works fine, just slower on the first call.

### Authentication: What Your Users Need

Since you're delegating to the user's own `claude` CLI, **your app doesn't handle auth at all**. The user must have already authenticated their CLI via one of:

- `claude auth login` (claude.ai subscription — Max, Pro, Team, etc.)
- `ANTHROPIC_API_KEY` environment variable
- Amazon Bedrock: `CLAUDE_CODE_USE_BEDROCK=1` + AWS credentials
- Google Vertex AI: `CLAUDE_CODE_USE_VERTEX=1` + GCP credentials
- Microsoft Azure: `CLAUDE_CODE_USE_FOUNDRY=1` + Azure credentials

Your app should check auth status on startup (t3code runs `claude auth status`) and show the user a message if they're not authenticated:

```typescript
import { execFileSync } from "child_process";

function checkClaudeAuth(binaryPath = "claude"): boolean {
  try {
    const output = execFileSync(binaryPath, ["auth", "status"], { encoding: "utf-8" });
    return !output.toLowerCase().includes("login required");
  } catch {
    return false; // CLI not installed or not authenticated
  }
}
```

> **Note:** Anthropic prohibits third-party products from *offering* claude.ai login flows. But using the user's *already-authenticated* CLI on their own machine is fine — you're not offering the login, the user did it themselves. This is the same model as VS Code, JetBrains extensions, and t3code.

**Package info:**
- npm: `@anthropic-ai/claude-agent-sdk`
- Current version: `0.2.114` (as of 2026-04-18)
- Source: `github.com/anthropics/claude-agent-sdk-typescript`
- License: See LICENSE in README.md
- Publicly available, no private registry needed

---

## 3. Architecture Overview

### t3code's Architecture (Reference)

```
┌─────────────────────────────────────────────────────────┐
│  Browser (React SPA)                                     │
│  ┌──────────────┐  ┌────────────┐  ┌─────────────────┐  │
│  │ ChatComposer │  │ Messages   │  │ Approval Panel  │  │
│  │              │  │ Timeline   │  │                 │  │
│  └──────┬───────┘  └─────▲──────┘  └────────┬────────┘  │
│         │                │                   │           │
│         └────────────────┼───────────────────┘           │
│                          │                               │
│              WebSocket RPC (single /ws)                   │
└──────────────────────────┼───────────────────────────────┘
                           │
┌──────────────────────────┼───────────────────────────────┐
│  Server (Node.js)        │                               │
│                          │                               │
│  ┌───────────────────────▼──────────────────────────┐    │
│  │           WebSocket RPC Handler                   │    │
│  │   dispatchCommand / subscribeThread / etc.        │    │
│  └───────────────────────┬──────────────────────────┘    │
│                          │                               │
│  ┌───────────────────────▼──────────────────────────┐    │
│  │          Orchestration Engine                     │    │
│  │   Event store, domain events, command reactor     │    │
│  └───────────────────────┬──────────────────────────┘    │
│                          │                               │
│  ┌───────────────────────▼──────────────────────────┐    │
│  │          Claude Adapter (Provider Layer)           │    │
│  │   Wraps @anthropic-ai/claude-agent-sdk            │    │
│  │   Maps SDKMessage → ProviderRuntimeEvent          │    │
│  │   Manages prompt queue, canUseTool callback        │    │
│  └───────────────────────┬──────────────────────────┘    │
│                          │                               │
│              query({ prompt, options })                   │
│                          │                               │
│  ┌───────────────────────▼──────────────────────────┐    │
│  │    @anthropic-ai/claude-agent-sdk                 │    │
│  │    Spawns `claude` CLI, communicates via NDJSON    │    │
│  └───────────────────────┬──────────────────────────┘    │
│                          │                               │
│              Child process: claude CLI                    │
└──────────────────────────────────────────────────────────┘
```

### Simplified Architecture (What You Need)

For a minimal integration, you can collapse the orchestration layer:

```
Browser UI  <──WebSocket──>  Your Server  <──SDK query()──>  claude CLI
```

Your server needs to:
1. Accept WebSocket connections from the browser
2. Map incoming user messages to `SDKUserMessage` objects pushed into a prompt queue
3. Iterate the `AsyncIterable<SDKMessage>` and forward events to the browser
4. Handle tool approval requests by prompting the user and returning the decision
5. Manage session lifecycle (start, stop, resume, interrupt)

---

## 4. SDK API Reference

### `query()` Function

```typescript
import { query } from "@anthropic-ai/claude-agent-sdk";

const session = query({
  prompt: string | AsyncIterable<SDKUserMessage>,
  options: Options,
});
```

**Returns:** A `Query` object:

```typescript
// Query extends AsyncGenerator — NOT AsyncIterable.
// Use for-await-of to iterate, use return() to stop (NOT close()).
interface Query extends AsyncGenerator<SDKMessage, void> {
  // Control methods (only work in streaming input mode)
  interrupt(): Promise<void>;           // Interrupt current turn
  setModel(model?: string): Promise<void>;
  setPermissionMode(mode: PermissionMode): Promise<void>;
  setMaxThinkingTokens(n: number | null): Promise<void>; // deprecated — use thinking option
  applyFlagSettings(settings: Settings): Promise<void>;   // merge settings mid-session

  // Query methods
  initializationResult(): Promise<SDKControlInitializeResponse>;
  supportedCommands(): Promise<SlashCommand[]>;
  supportedModels(): Promise<ModelInfo[]>;
  supportedAgents(): Promise<AgentInfo[]>;
  mcpServerStatus(): Promise<McpServerStatus[]>;
  getContextUsage(): Promise<SDKControlGetContextUsageResponse>;
  accountInfo(): Promise<AccountInfo>;

  // File checkpointing (requires enableFileCheckpointing option)
  rewindFiles(userMessageId: string, options?: { dryRun?: boolean }): Promise<RewindFilesResult>;

  // MCP server management
  reconnectMcpServer(serverName: string): Promise<void>;
  toggleMcpServer(serverName: string, enabled: boolean): Promise<void>;
}
```

> **IMPORTANT:** `Query` extends `AsyncGenerator`, which has `return()` — NOT `close()`. To kill a session: `runtime.return(undefined)`. Do NOT call a non-existent `close()` method.

### `startup()` — Pre-warm a Subprocess

See [Section 14.1](#141-pre-warming-with-startup) for full details.

```typescript
import { startup } from "@anthropic-ai/claude-agent-sdk";

// Returns WarmQuery (extends AsyncDisposable — requires Node >= 20)
const warm = await startup({ options: { pathToClaudeCodeExecutable: "claude", env: process.env } });
const session = warm.query(promptQueue);  // Use warmQuery.query(), NOT standalone query()
// warm.close();  // If you don't end up using it
```

### Session Utility Functions

```typescript
import {
  listSessions,
  getSessionInfo,
  getSessionMessages,
  renameSession,
  deleteSession,
  listSubagents,
} from "@anthropic-ai/claude-agent-sdk";

// List all persisted sessions from disk
const sessions = await listSessions({
  dir?: string;          // Custom session directory (default: ~/.claude/sessions/)
  limit?: number;        // Max results
  offset?: number;       // Pagination offset
  includeWorktrees?: boolean;
  sessionStore?: SessionStore;
});
// Returns: SDKSessionInfo[]  — each has { session_id, created_at, updated_at, title, ... }

// Get metadata for a specific session
const info = await getSessionInfo(sessionId, { dir? });

// Load message history from a persisted session
const messages = await getSessionMessages(sessionId, {
  dir?: string;
  limit?: number;
  offset?: number;
  includeSystemMessages?: boolean;
});
// Returns: SessionMessage[]

// Rename a session
await renameSession(sessionId, "New Title", { dir? });

// Delete a session from disk
await deleteSession(sessionId, { dir? });

// List sub-agents spawned by a session
const subagents = await listSubagents(sessionId, options);
```

**Typical use:** Call `listSessions()` to populate a session browser UI. When the user selects a session, call `getSessionMessages()` to display the history, then `query({ options: { resume: sessionId } })` to continue it.

### Options (ClaudeQueryOptions)

```typescript
interface Options {
  // === Required / Core ===
  cwd?: string;                            // Working directory for the agent
  pathToClaudeCodeExecutable?: string;     // Path to `claude` binary (default: "claude")
  env?: { [envVar: string]: string | undefined };  // Environment variables for the CLI process

  // === Model & Quality ===
  model?: string;                          // e.g. "claude-opus-4-6", "claude-sonnet-4-6"
  fallbackModel?: string;                  // Fallback if primary model unavailable
  effort?: "low" | "medium" | "high" | "xhigh" | "max";
  // NOTE: "xhigh" is Opus 4.7 only. effort is session-creation-only — cannot be changed mid-session.
  thinking?: ThinkingConfig;
  // ThinkingConfig:
  //   { type: "adaptive" }                       — auto budget
  //   { type: "enabled"; budgetTokens: number }  — explicit budget
  //   { type: "disabled" }                       — disable thinking
  maxThinkingTokens?: number;              // Deprecated — use thinking instead

  // === Session Identity ===
  sessionId?: string;                      // UUID to assign to new session (cannot use with resume unless forkSession: true)
  resume?: string;                         // UUID of session to resume
  resumeSessionAt?: string;               // Assistant message UUID to resume from specific point
  forkSession?: boolean;                  // REQUIRED when using both resume and sessionId (fork to new session)
  continue?: boolean;                     // Resume most recent session in cwd (mutually exclusive with resume)
  persistSession?: boolean;               // false = ephemeral, won't be saved to disk
  sessionStore?: SessionStore;            // Custom session store (default: disk)
  mirrorSession?: SessionStore;           // Mirror session data to a secondary store

  // === Permissions ===
  permissionMode?: PermissionMode;         // "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk"
  allowDangerouslySkipPermissions?: boolean;   // Required when permissionMode = "bypassPermissions"
  permissionPromptToolName?: string;       // Custom tool name for permission prompts
  canUseTool?: CanUseTool;                // Callback for tool approval (see section 10)
  allowedTools?: string[];                // Whitelist tools (all others blocked)
  disallowedTools?: string[];             // Blacklist tools (all others allowed)

  // === Tools & Agents ===
  tools?: string[] | { type: "preset"; preset: "claude_code" };  // Custom tools or preset
  toolConfig?: ToolConfig;                // Per-tool configuration
  agent?: string;                         // Which agent definition to use
  agents?: Record<string, AgentDefinition>;   // Custom agent definitions
  mcpServers?: Record<string, McpServerConfig>;  // MCP server configs (per-session)
  plugins?: SdkPluginConfig[];            // SDK plugin configs
  hooks?: Partial<Record<HookEvent, HookCallbackMatcher[]>>;  // Lifecycle hooks
  onElicitation?: OnElicitation;          // Callback when agent requests user input

  // === Settings ===
  settingSources?: SettingSource[];        // ["user", "project", "local"] — which settings files to load
  betas?: SdkBeta[];                      // Enable beta features

  // === Streaming ===
  includePartialMessages?: boolean;        // Get streaming text/thinking deltas
  includeHookEvents?: boolean;            // Include hook_started/progress/response events

  // === Limits ===
  maxTurns?: number;                       // Max agent turns (0 = init only — useful for probing without tokens)
  maxBudgetUsd?: number;                  // Hard stop when cost reaches this (USD)
  taskBudget?: { total: number };         // Budget in task units

  // === File Checkpointing ===
  enableFileCheckpointing?: boolean;       // Enable file state checkpointing (required for rewindFiles())

  // === Output ===
  outputFormat?: OutputFormat;            // Format for result output

  // === Environment ===
  additionalDirectories?: string[];       // Extra directories the agent can access
  executable?: "bun" | "deno" | "node";  // Runtime for bundled binary
  executableArgs?: string[];             // Extra args passed to the executable
  extraArgs?: Record<string, string | null>;  // Extra CLI flags

  // === Lifecycle ===
  abortController?: AbortController;      // External abort control
  signal?: AbortSignal;                   // Alternative abort signal
  loadTimeoutMs?: number;                 // Timeout for initialization (ms)

  // === Diagnostics ===
  stderr?: (data: unknown) => void;       // Capture stderr from the subprocess

  // === UI Hints ===
  promptSuggestions?: boolean;            // Enable prompt suggestion UI
  agentProgressSummaries?: boolean;       // Emit progress summaries for sub-agents

  // === Sandbox ===
  sandbox?: SandboxSettings;             // Sandbox configuration for tool execution
}
```

### PermissionMode

```typescript
type PermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk";
```

| Mode | Behavior |
|------|----------|
| `"default"` | Ask for approval on every tool call |
| `"acceptEdits"` | Auto-approve file edits, ask for others |
| `"bypassPermissions"` | Auto-approve everything (requires `allowDangerouslySkipPermissions: true`) |
| `"plan"` | Plan mode — agent proposes but doesn't execute |
| `"dontAsk"` | Skip permission prompts without full bypass (less permissive than `bypassPermissions`) |

### SDKMessage (All Variants)

The `Query` (AsyncGenerator) yields a discriminated union on `message.type`:

```typescript
type SDKMessage =
  | { type: "stream_event";     session_id: string; event: StreamEvent; uuid?: string }
  | { type: "user";             session_id: string; message: UserMessageContent; uuid?: string }
  | { type: "assistant";        session_id: string; message: AssistantMessageContent; uuid: string }
  | { type: "result";           session_id: string; subtype: string; is_error: boolean; errors: string[]; usage?: Usage }
  | { type: "system";           session_id: string; subtype: SystemSubtype; message: SystemPayload }
  | { type: "tool_progress";    session_id: string; tool_use_id: string; tool_name: string; elapsed_time_seconds: number }
  | { type: "tool_use_summary"; session_id: string; summary: string; preceding_tool_use_ids: string[] }
  | { type: "auth_status";      session_id: string; isAuthenticating: boolean; output: string; error?: string }
  | { type: "rate_limit_event"; session_id: string; uuid: string; rate_limit_info: RateLimitInfo }
```

#### RateLimitInfo

The `rate_limit_info` field on `rate_limit_event` messages has two shapes depending on the limit type:

```typescript
// Full shape (five_hour rate limit — most common):
interface RateLimitInfo {
  status: "allowed" | "limited";        // Whether the request was allowed
  resetsAt: number;                     // Unix timestamp (seconds) when this limit resets
  rateLimitType?: "five_hour";          // Present on five-hour window limits
  overageStatus?: "allowed" | "limited"; // Overage allowance status
  overageResetsAt?: number;             // Unix timestamp when overage resets
  isUsingOverage: boolean;              // Whether currently consuming overage budget
}

// Minimal shape (simple rate check):
interface RateLimitInfo {
  status: "allowed" | "limited";
  resetsAt: number;
  isUsingOverage: boolean;
}
```

The `rateLimitType`, `overageStatus`, and `overageResetsAt` fields are only present on five-hour window rate limit events. Simpler rate checks omit them.

#### StreamEvent Sub-types

When `type === "stream_event"`, the `event` field contains:

| `event.type` | Description |
|--------------|-------------|
| `content_block_start` | New content block beginning (text or tool_use) |
| `content_block_delta` | Streaming delta: `text_delta`, `thinking_delta`, or `input_json_delta` |
| `content_block_stop` | Content block completed |
| `message_start` | New message beginning |
| `message_delta` | Message-level delta |
| `message_stop` | Message completed |

#### System Message Sub-types

When `type === "system"`, the `subtype` field is:

| `subtype` | Description |
|-----------|-------------|
| `"init"` | Initialization complete — contains account info, available commands |
| `"status"` | Status change: `"compacting"`, `"active"` |
| `"compact_boundary"` | Context was compacted |
| `"hook_started"` | A hook began executing |
| `"hook_progress"` | Hook output/progress |
| `"hook_response"` | Hook completed |
| `"task_started"` | Sub-agent task started |
| `"task_progress"` | Sub-agent task progress |
| `"task_notification"` | Sub-agent task completed |
| `"files_persisted"` | Files were saved |

### SDKUserMessage

```typescript
interface SDKUserMessage {
  type: "user";
  session_id: string;
  parent_tool_use_id: string | null;
  message: {
    role: "user";
    content: string | ContentBlock[];
  };
}

// ContentBlock variants:
type ContentBlock =
  | { type: "text"; text: string }
  | { type: "image"; source: { type: "base64"; media_type: string; data: string } }
  | { type: "tool_result"; tool_use_id: string; content: string; is_error: boolean }
```

### SDKResultMessage

```typescript
interface SDKResultMessage {
  type: "result";
  session_id: string;
  subtype: "success" | "error_during_execution";
  is_error: boolean;
  errors: string[];
  stop_reason?: string;
  usage?: {
    input_tokens: number;
    cache_creation_input_tokens?: number;
    cache_read_input_tokens?: number;
    output_tokens: number;
    total_tokens?: number;
    tool_uses?: number;
    duration_ms?: number;
  };
  total_cost_usd?: number;
}
```

### SDKControlInitializeResponse

Returned by `runtime.initializationResult()`:

```typescript
interface SDKControlInitializeResponse {
  commands: SlashCommand[];          // Available slash commands (e.g. "/compact", "/clear")
  agents: AgentInfo[];               // Available agent definitions
  models: ModelInfo[];               // Available models for this account
  account: AccountInfo;             // Account info (subscriptionType, etc.)
  output_style: string;              // Current output style
  available_output_styles: string[]; // Supported output styles
  fast_mode_state?: "off" | "cooldown" | "on";  // Current fast mode status
}

// AccountInfo:
interface AccountInfo {
  subscriptionType?: string;  // "max", "pro", "enterprise", "free", "team"
  // ... other account fields
}
```

---

## 5. Building the Server Adapter

The server adapter wraps the SDK and exposes a WebSocket API. Here's how t3code structures it (simplified for your use):

### Core Concept: The Prompt Queue

The SDK accepts prompts as an `AsyncIterable<SDKUserMessage>`. The trick is to back this with a queue that your WebSocket handler can push messages into:

```typescript
// Pseudocode — the pattern t3code uses with Effect's Queue
// For a plain implementation, use an async generator or a simple queue

class PromptQueue {
  private queue: Array<SDKUserMessage> = [];
  private resolve: ((value: IteratorResult<SDKUserMessage>) => void) | null = null;
  private done = false;

  push(message: SDKUserMessage) {
    if (this.resolve) {
      this.resolve({ value: message, done: false });
      this.resolve = null;
    } else {
      this.queue.push(message);
    }
  }

  close() {
    this.done = true;
    if (this.resolve) {
      this.resolve({ value: undefined as any, done: true });
      this.resolve = null;
    }
  }

  [Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
    return {
      next: () => {
        if (this.queue.length > 0) {
          return Promise.resolve({ value: this.queue.shift()!, done: false });
        }
        if (this.done) {
          return Promise.resolve({ value: undefined as any, done: true });
        }
        return new Promise((resolve) => { this.resolve = resolve; });
      }
    };
  }
}
```

### Session Manager

```typescript
import { query, type SDKMessage, type SDKUserMessage, type Options, type Query } from "@anthropic-ai/claude-agent-sdk";

interface Session {
  id: string;
  promptQueue: PromptQueue;
  runtime: Query;
  stopped: boolean;
}

const sessions = new Map<string, Session>();

function startSession(sessionId: string, cwd: string, options?: Partial<Options>) {
  const promptQueue = new PromptQueue();

  const runtime = query({
    prompt: promptQueue,
    options: {
      cwd,
      sessionId,
      pathToClaudeCodeExecutable: "claude",  // or full path
      permissionMode: "default",
      includePartialMessages: true,
      settingSources: ["user", "project", "local"],
      canUseTool: (toolName, toolInput, callbackOptions) => {
        // Bridge to WebSocket — see section 10
        return waitForUserApproval(sessionId, toolName, toolInput);
      },
      ...options,
    },
  });

  const session: Session = { id: sessionId, promptQueue, runtime, stopped: false };
  sessions.set(sessionId, session);

  // Start consuming the message stream
  consumeStream(session);

  return session;
}
```

### Stream Consumer

```typescript
async function consumeStream(session: Session) {
  try {
    for await (const message of session.runtime) {
      if (session.stopped) break;
      // Forward to all connected WebSocket clients for this session
      broadcastToClients(session.id, message);
    }
  } catch (error) {
    broadcastToClients(session.id, {
      type: "error",
      error: String(error),
    });
  }
}
```

---

## 6. Building the Real-Time Transport Layer

### Option A: WebSocket (Recommended — what t3code uses)

A single WebSocket connection carries all communication: commands from the client, events from the server.

```typescript
import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 3001 });

wss.on("connection", (ws) => {
  let sessionId: string | null = null;

  ws.on("message", (data) => {
    const msg = JSON.parse(data.toString());

    switch (msg.type) {
      case "session.start":
        sessionId = msg.sessionId || crypto.randomUUID();
        const session = startSession(sessionId, msg.cwd, msg.options);
        ws.send(JSON.stringify({ type: "session.started", sessionId }));
        // Register this WS client to receive events
        registerClient(sessionId, ws);
        break;

      case "session.resume":
        sessionId = msg.sessionId;
        resumeSession(sessionId, ws);
        break;

      case "turn.start":
        sendMessage(sessionId, msg.text, msg.attachments);
        break;

      case "turn.interrupt":
        interruptSession(sessionId);
        break;

      case "session.stop":
        stopSession(sessionId);
        break;

      case "approval.respond":
        resolveApproval(sessionId, msg.requestId, msg.decision);
        break;
    }
  });

  ws.on("close", () => {
    if (sessionId) unregisterClient(sessionId, ws);
  });
});
```

### Option B: Server-Sent Events (SSE) + REST

If you prefer HTTP-only:
- `POST /sessions` — start a session
- `POST /sessions/:id/messages` — send a message
- `POST /sessions/:id/interrupt` — interrupt
- `POST /sessions/:id/approve` — respond to approval
- `GET /sessions/:id/events` — SSE stream of all events

SSE is simpler but uni-directional — the client must use fetch/POST for commands. t3code chose WebSocket for bidirectional communication.

---

## 7. Session Lifecycle

### Starting a New Session

```typescript
function startSession(sessionId: string, cwd: string, initialPrompt?: string) {
  const promptQueue = new PromptQueue();

  const runtime = query({
    prompt: initialPrompt
      ? initialPrompt          // Single string prompt for one-shot
      : promptQueue,           // AsyncIterable for multi-turn
    options: {
      cwd,
      sessionId,
      pathToClaudeCodeExecutable: "claude",
      permissionMode: "default",
      includePartialMessages: true,
      canUseTool: makeToolApprovalHandler(sessionId),
    },
  });

  // Wait for initialization
  runtime.initializationResult().then((init) => {
    broadcastToClients(sessionId, {
      type: "session.initialized",
      account: init.account,
      commands: init.commands,
    });
  });

  // Start consuming stream
  consumeStream(session);
}
```

### Resuming a Session

Claude Code persists sessions to disk. To resume:

```typescript
function resumeSession(previousSessionId: string, cwd: string) {
  const promptQueue = new PromptQueue();

  // IMPORTANT: Do NOT pass sessionId together with resume unless forkSession: true.
  // Passing sessionId + resume without forkSession causes the SDK to exit with code 1.
  const runtime = query({
    prompt: promptQueue,
    options: {
      cwd,
      resume: previousSessionId,     // <-- The UUID of the session to resume
      // sessionId is intentionally omitted — the SDK assigns a new one automatically.
      // If you need to fork to a specific new ID: { resume, sessionId: newId, forkSession: true }
      pathToClaudeCodeExecutable: "claude",
      permissionMode: "default",
      includePartialMessages: true,
      canUseTool: makeToolApprovalHandler(previousSessionId),
    },
  });

  // ... same as startSession
}
```

### Stopping a Session

```typescript
function stopSession(sessionId: string) {
  const session = sessions.get(sessionId);
  if (!session) return;

  session.stopped = true;
  session.promptQueue.close();          // Signals the SDK to stop reading prompts
  session.runtime.return(undefined);    // Kills the underlying CLI process (AsyncGenerator.return())

  broadcastToClients(sessionId, { type: "session.stopped" });
  sessions.delete(sessionId);
}
```

### Interrupting a Turn

Interrupt stops the current turn but keeps the session alive:

```typescript
async function interruptSession(sessionId: string) {
  const session = sessions.get(sessionId);
  if (!session) return;

  await session.runtime.interrupt();
  // The SDK will emit a "result" message with appropriate stop_reason
}
```

---

## 8. Streaming Messages to the Client

### What to Forward

Not every `SDKMessage` needs to be forwarded to the client. Here's the mapping t3code uses:

| SDKMessage type | Forward to client? | Client event | Purpose |
|---|---|---|---|
| `stream_event` (content_block_delta, text_delta) | **Yes** | `content.delta` | Streaming text from the assistant |
| `stream_event` (content_block_delta, thinking_delta) | **Yes** | `thinking.delta` | Thinking/reasoning content |
| `stream_event` (content_block_delta, input_json_delta) | **Yes** | `tool.input.delta` | Tool call input being composed |
| `stream_event` (content_block_start) | **Yes** | `block.start` | New text or tool block starting |
| `stream_event` (content_block_stop) | **Yes** | `block.stop` | Block completed |
| `assistant` | **Yes** | `assistant.message` | Complete assistant message snapshot |
| `result` | **Yes** | `turn.completed` | Turn finished (success or error) |
| `system` (init) | **Yes** | `session.configured` | Session ready |
| `system` (status) | **Yes** | `session.state` | Compacting, active, etc. |
| `system` (task_started/progress/notification) | **Yes** | `task.*` | Sub-agent activity |
| `system` (hook_*) | Optional | `hook.*` | Hook execution events |
| `tool_progress` | **Yes** | `tool.progress` | Long-running tool updates |
| `tool_use_summary` | **Yes** | `tool.summary` | Tool execution summary |
| `user` (tool results) | **Yes** | `tool.result` | Tool execution results |
| `auth_status` | **Yes** | `auth.status` | Auth state changes |
| `rate_limit_event` | Optional | `rate_limit` | Rate limiting info |

### Example Message Processing

```typescript
function processMessage(sessionId: string, message: SDKMessage) {
  switch (message.type) {
    case "stream_event": {
      const event = message.event;
      if (event.type === "content_block_delta") {
        if (event.delta.type === "text_delta") {
          broadcastToClients(sessionId, {
            type: "content.delta",
            text: event.delta.text,
            index: event.index,
          });
        } else if (event.delta.type === "thinking_delta") {
          broadcastToClients(sessionId, {
            type: "thinking.delta",
            text: event.delta.text,
            index: event.index,
          });
        } else if (event.delta.type === "input_json_delta") {
          broadcastToClients(sessionId, {
            type: "tool.input.delta",
            partialJson: event.delta.partial_json,
            index: event.index,
          });
        }
      } else if (event.type === "content_block_start") {
        broadcastToClients(sessionId, {
          type: "block.start",
          blockType: event.content_block?.type,
          blockId: event.content_block?.id,
          index: event.index,
        });
      } else if (event.type === "content_block_stop") {
        broadcastToClients(sessionId, {
          type: "block.stop",
          index: event.index,
        });
      }
      break;
    }

    case "assistant":
      broadcastToClients(sessionId, {
        type: "assistant.message",
        content: message.message.content,
        uuid: message.uuid,
      });
      break;

    case "result":
      broadcastToClients(sessionId, {
        type: "turn.completed",
        success: !message.is_error,
        errors: message.errors,
        usage: message.usage,
        cost: message.total_cost_usd,
      });
      break;

    case "system":
      handleSystemMessage(sessionId, message);
      break;

    case "tool_progress":
      broadcastToClients(sessionId, {
        type: "tool.progress",
        toolName: message.tool_name,
        toolUseId: message.tool_use_id,
        elapsed: message.elapsed_time_seconds,
      });
      break;
  }
}
```

---

## 9. Handling User Input

### Sending a Message (Starting a Turn)

```typescript
function sendMessage(sessionId: string, text: string, attachments?: Attachment[]) {
  const session = sessions.get(sessionId);
  if (!session) throw new Error("Session not found");

  const content: ContentBlock[] = [{ type: "text", text }];

  // Add image attachments if any
  if (attachments) {
    for (const att of attachments) {
      if (att.type === "image") {
        content.push({
          type: "image",
          source: {
            type: "base64",
            media_type: att.mediaType,
            data: att.base64Data,
          },
        });
      }
    }
  }

  const userMessage: SDKUserMessage = {
    type: "user",
    session_id: session.id,
    parent_tool_use_id: null,
    message: {
      role: "user",
      content,
    },
  };

  session.promptQueue.push(userMessage);
}
```

### Sending While Session Is Working

The prompt queue allows enqueueing messages at any time. If the agent is mid-turn, the next message will be picked up after the current turn completes. To interrupt and send immediately:

```typescript
async function interruptAndSend(sessionId: string, text: string) {
  await interruptSession(sessionId);
  // Small delay to let the interrupt propagate
  sendMessage(sessionId, text);
}
```

---

## 10. Tool Approval Flow

When `permissionMode` is `"default"`, the SDK calls your `canUseTool` callback before executing each tool. This is the bridge between the agent and your UI's approval panel.

### How t3code Does It

t3code uses a deferred/promise pattern:
1. Agent calls `canUseTool(toolName, toolInput, options)`
2. The callback stores a pending approval request and sends it to the browser via WebSocket
3. The callback returns a Promise that resolves when the user responds
4. The browser user clicks Approve/Deny, which sends a WebSocket message back
5. The server resolves the pending Promise, returning the decision to the SDK

### Implementation

```typescript
// Pending approval requests
const pendingApprovals = new Map<string, {
  resolve: (result: PermissionResult) => void;
  toolName: string;
  toolInput: Record<string, unknown>;
}>();

function makeToolApprovalHandler(sessionId: string): CanUseTool {
  return (toolName, toolInput, callbackOptions) => {
    const requestId = crypto.randomUUID();

    // Send the approval request to the client
    broadcastToClients(sessionId, {
      type: "approval.requested",
      requestId,
      toolName,
      toolInput,
      suggestions: callbackOptions.suggestions,  // SDK may suggest auto-approve rules
    });

    // Return a promise that resolves when the user responds
    return new Promise<PermissionResult>((resolve) => {
      pendingApprovals.set(requestId, { resolve, toolName, toolInput });

      // Also listen for abort (if the session is interrupted)
      callbackOptions.signal.addEventListener("abort", () => {
        pendingApprovals.delete(requestId);
        resolve({ behavior: "deny", message: "Session interrupted" });
      });
    });
  };
}

function resolveApproval(
  sessionId: string,
  requestId: string,
  decision: "allow" | "deny",
  message?: string,
  updatedInput?: Record<string, unknown>
) {
  const pending = pendingApprovals.get(requestId);
  if (!pending) return;

  pendingApprovals.delete(requestId);

  if (decision === "allow") {
    pending.resolve({ behavior: "allow", updatedInput });
  } else {
    pending.resolve({ behavior: "deny", message });
  }
}
```

### Client-Side Approval UI

```typescript
// In your frontend
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  if (msg.type === "approval.requested") {
    // Show approval dialog
    showApprovalDialog({
      requestId: msg.requestId,
      toolName: msg.toolName,
      toolInput: msg.toolInput,
      onApprove: () => {
        ws.send(JSON.stringify({
          type: "approval.respond",
          requestId: msg.requestId,
          decision: "allow",
        }));
      },
      onDeny: (reason) => {
        ws.send(JSON.stringify({
          type: "approval.respond",
          requestId: msg.requestId,
          decision: "deny",
          message: reason,
        }));
      },
    });
  }
};
```

---

## 11. Resume & Persistence

### How Claude Code Persists Sessions

Claude Code saves session state to disk automatically (unless `persistSession: false`). Each session has a UUID that can be used to resume it later.

### Tracking Resume State

Store the `session_id` from SDK messages to use as the resume token:

```typescript
let lastSessionId: string | null = null;

// In your stream consumer
for await (const message of session.runtime) {
  if (message.session_id) {
    lastSessionId = message.session_id;
  }
  // ... process message
}

// Save lastSessionId to your database for later resumption
```

### Resume Flow

```typescript
// Later, in a new session:
const runtime = query({
  prompt: promptQueue,
  options: {
    cwd: "/path/to/project",
    resume: savedSessionId,  // The session_id from a previous session
    // ... other options
  },
});
```

### t3code's Resume Cursor

t3code stores a richer resume cursor:

```typescript
interface ClaudeResumeCursor {
  threadId: string;            // Internal thread identifier
  resume: string;              // Session UUID
  resumeSessionAt: string;     // Assistant message UUID (to resume from a specific point)
  turnCount: number;           // How many turns have been completed
}
```

This allows resuming from a specific point within a session, not just the end.

---

## 12. Complete Data Flow Diagram

```
┌─────────────────────────────────────────────────────────────────────┐
│                         BROWSER CLIENT                              │
│                                                                     │
│  User types message ──→ ws.send({ type: "turn.start", text })      │
│                                                                     │
│  ← ws.onmessage ──── { type: "content.delta", text: "..." }       │
│  ← ws.onmessage ──── { type: "tool.progress", ... }               │
│  ← ws.onmessage ──── { type: "approval.requested", ... }          │
│  User clicks Approve → ws.send({ type: "approval.respond", ... }) │
│  ← ws.onmessage ──── { type: "content.delta", text: "..." }       │
│  ← ws.onmessage ──── { type: "turn.completed", ... }              │
│                                                                     │
│  User clicks Stop ───→ ws.send({ type: "turn.interrupt" })        │
└─────────────────────┬───────────────────────────────────────────────┘
                      │ WebSocket
                      │
┌─────────────────────▼───────────────────────────────────────────────┐
│                         YOUR SERVER                                  │
│                                                                     │
│  WebSocket Handler                                                  │
│    │                                                                │
│    ├─ "turn.start" ───→ promptQueue.push(SDKUserMessage)           │
│    │                         │                                      │
│    │                         ▼                                      │
│    │                    AsyncIterable<SDKUserMessage>                │
│    │                         │                                      │
│    │                         ▼                                      │
│    │                    query({ prompt, options })                   │
│    │                         │                                      │
│    │                         │ (SDK spawns `claude` CLI internally) │
│    │                         │                                      │
│    │                         ▼                                      │
│    │                    AsyncIterable<SDKMessage>                    │
│    │                         │                                      │
│    │                    for await (const msg of runtime) {           │
│    │                      processMessage(sessionId, msg)             │
│    │                      → broadcastToClients(sessionId, event)     │
│    │                    }                                            │
│    │                                                                │
│    ├─ "turn.interrupt" → runtime.interrupt()                       │
│    │                                                                │
│    ├─ "session.stop" ──→ runtime.return(undefined)                 │
│    │                      promptQueue.close()                       │
│    │                                                                │
│    └─ "approval.respond" → resolve pending canUseTool promise      │
│                                                                     │
│  Tool Approval Bridge (canUseTool callback)                         │
│    │                                                                │
│    ├─ SDK calls canUseTool(toolName, input)                        │
│    ├─ → broadcastToClients({ type: "approval.requested", ... })    │
│    ├─ → return new Promise (waits for user response)               │
│    └─ → resolves with { behavior: "allow" } or { behavior: "deny" }│
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
                      │
                      │ (Managed by SDK internally)
                      │
┌─────────────────────▼───────────────────────────────────────────────┐
│            Claude Code binary (child subprocess)                     │
│            (bundled by SDK or user-installed `claude` CLI)           │
│                                                                     │
│  Receives prompts via NDJSON on stdin                               │
│  Sends events via NDJSON on stdout                                  │
│  Calls Anthropic API internally (using ANTHROPIC_API_KEY or         │
│    claude.ai login or Bedrock/Vertex/Azure credentials)             │
│  Executes tools (file read/write, bash, etc.)                       │
│  Manages context window, compaction, tool definitions               │
│  Persists sessions to ~/.claude/sessions/                           │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
```

---

## 13. Minimal Working Implementation

A complete, minimal server you can use as a starting point:

```typescript
// server.ts
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { WebSocketServer, WebSocket } from "ws";
import { randomUUID } from "crypto";

// ─── Prompt Queue ────────────────────────────────────────────────

class PromptQueue {
  private items: SDKUserMessage[] = [];
  private waiter: ((result: IteratorResult<SDKUserMessage>) => void) | null = null;
  private closed = false;

  push(msg: SDKUserMessage) {
    if (this.waiter) {
      const w = this.waiter;
      this.waiter = null;
      w({ value: msg, done: false });
    } else {
      this.items.push(msg);
    }
  }

  close() {
    this.closed = true;
    if (this.waiter) {
      const w = this.waiter;
      this.waiter = null;
      w({ value: undefined as any, done: true });
    }
  }

  [Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
    return {
      next: (): Promise<IteratorResult<SDKUserMessage>> => {
        if (this.items.length > 0) {
          return Promise.resolve({ value: this.items.shift()!, done: false });
        }
        if (this.closed) {
          return Promise.resolve({ value: undefined as any, done: true });
        }
        return new Promise(resolve => { this.waiter = resolve; });
      },
    };
  }
}

// ─── Session Store ───────────────────────────────────────────────

interface Session {
  id: string;
  queue: PromptQueue;
  runtime: ReturnType<typeof query>;
  clients: Set<WebSocket>;
  pendingApprovals: Map<string, (result: any) => void>;
}

const sessions = new Map<string, Session>();

// ─── Broadcast ───────────────────────────────────────────────────

function broadcast(session: Session, data: object) {
  const json = JSON.stringify(data);
  for (const ws of session.clients) {
    if (ws.readyState === WebSocket.OPEN) ws.send(json);
  }
}

// ─── Stream Consumer ─────────────────────────────────────────────

async function consume(session: Session) {
  try {
    for await (const msg of session.runtime) {
      broadcast(session, msg);
    }
  } catch (err) {
    broadcast(session, { type: "error", message: String(err) });
  } finally {
    broadcast(session, { type: "session.ended" });
  }
}

// ─── WebSocket Server ────────────────────────────────────────────

const wss = new WebSocketServer({ port: 3001 });
console.log("WebSocket server listening on ws://localhost:3001");

wss.on("connection", (ws) => {
  let currentSessionId: string | null = null;

  ws.on("message", (raw) => {
    const msg = JSON.parse(raw.toString());

    switch (msg.type) {
      case "session.start": {
        const id = msg.sessionId || randomUUID();
        const queue = new PromptQueue();
        const pendingApprovals = new Map();

        const runtime = query({
          prompt: queue,
          options: {
            cwd: msg.cwd || process.cwd(),
            sessionId: id,
            pathToClaudeCodeExecutable: msg.claudePath || "claude", // User's own CLI
            env: process.env,  // Inherit user's auth, API keys, everything
            permissionMode: msg.permissionMode || "default",
            includePartialMessages: true,
            settingSources: ["user", "project", "local"],  // Load user's settings
            ...(msg.model ? { model: msg.model } : {}),
            ...(msg.resume ? { resume: msg.resume } : {}),
            canUseTool: (toolName, toolInput, opts) => {
              const reqId = randomUUID();
              broadcast(session, {
                type: "approval.requested",
                requestId: reqId,
                toolName,
                toolInput,
              });
              return new Promise((resolve) => {
                pendingApprovals.set(reqId, resolve);
                opts.signal.addEventListener("abort", () => {
                  pendingApprovals.delete(reqId);
                  resolve({ behavior: "deny", message: "Aborted" });
                });
              });
            },
          },
        });

        const session: Session = { id, queue, runtime, clients: new Set([ws]), pendingApprovals };
        sessions.set(id, session);
        currentSessionId = id;

        ws.send(JSON.stringify({ type: "session.started", sessionId: id }));
        consume(session);
        break;
      }

      case "session.join": {
        const session = sessions.get(msg.sessionId);
        if (session) {
          session.clients.add(ws);
          currentSessionId = msg.sessionId;
          ws.send(JSON.stringify({ type: "session.joined", sessionId: msg.sessionId }));
        }
        break;
      }

      case "turn.start": {
        const session = sessions.get(currentSessionId!);
        if (!session) break;

        const content: any[] = [{ type: "text", text: msg.text }];
        if (msg.images) {
          for (const img of msg.images) {
            content.push({
              type: "image",
              source: { type: "base64", media_type: img.mediaType, data: img.data },
            });
          }
        }

        session.queue.push({
          type: "user",
          session_id: session.id,
          parent_tool_use_id: null,
          message: { role: "user", content },
        });
        break;
      }

      case "turn.interrupt": {
        const session = sessions.get(currentSessionId!);
        session?.runtime.interrupt();
        break;
      }

      case "session.stop": {
        const session = sessions.get(currentSessionId!);
        if (session) {
          session.queue.close();
          session.runtime.return(undefined).catch(() => {});  // AsyncGenerator.return(), NOT .close()
          sessions.delete(currentSessionId!);
        }
        break;
      }

      case "approval.respond": {
        const session = sessions.get(currentSessionId!);
        const resolve = session?.pendingApprovals.get(msg.requestId);
        if (resolve) {
          session!.pendingApprovals.delete(msg.requestId);
          resolve(
            msg.decision === "allow"
              ? { behavior: "allow" }
              : { behavior: "deny", message: msg.reason }
          );
        }
        break;
      }

      case "model.set": {
        const session = sessions.get(currentSessionId!);
        session?.runtime.setModel(msg.model);
        break;
      }
    }
  });

  ws.on("close", () => {
    if (currentSessionId) {
      const session = sessions.get(currentSessionId);
      session?.clients.delete(ws);
    }
  });
});
```

### Minimal Client

```html
<!DOCTYPE html>
<html>
<body>
  <div id="messages" style="height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 8px;"></div>
  <input id="input" type="text" style="width: 80%;" placeholder="Type a message..." />
  <button id="send">Send</button>
  <button id="stop">Stop</button>

  <div id="approval" style="display:none; background: #ffe0b2; padding: 8px; margin: 8px 0;">
    <p id="approval-text"></p>
    <button id="approve">Approve</button>
    <button id="deny">Deny</button>
  </div>

  <script>
    const ws = new WebSocket("ws://localhost:3001");
    const messages = document.getElementById("messages");
    let currentText = "";
    let pendingApprovalId = null;

    ws.onopen = () => {
      ws.send(JSON.stringify({
        type: "session.start",
        cwd: "/path/to/your/project",
        permissionMode: "default",
      }));
    };

    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);

      if (msg.type === "stream_event" && msg.event?.type === "content_block_delta") {
        if (msg.event.delta?.type === "text_delta") {
          currentText += msg.event.delta.text;
          updateLastAssistantMessage(currentText);
        }
      } else if (msg.type === "result") {
        currentText = "";
        appendMessage("system", msg.is_error ? `Error: ${msg.errors[0]}` : "Turn completed");
      } else if (msg.type === "approval.requested") {
        pendingApprovalId = msg.requestId;
        document.getElementById("approval-text").textContent =
          `Tool: ${msg.toolName}\nInput: ${JSON.stringify(msg.toolInput, null, 2)}`;
        document.getElementById("approval").style.display = "block";
      }
    };

    document.getElementById("send").onclick = () => {
      const input = document.getElementById("input");
      if (!input.value.trim()) return;
      appendMessage("user", input.value);
      ws.send(JSON.stringify({ type: "turn.start", text: input.value }));
      input.value = "";
    };

    document.getElementById("stop").onclick = () => {
      ws.send(JSON.stringify({ type: "turn.interrupt" }));
    };

    document.getElementById("approve").onclick = () => {
      ws.send(JSON.stringify({ type: "approval.respond", requestId: pendingApprovalId, decision: "allow" }));
      document.getElementById("approval").style.display = "none";
    };

    document.getElementById("deny").onclick = () => {
      ws.send(JSON.stringify({ type: "approval.respond", requestId: pendingApprovalId, decision: "deny" }));
      document.getElementById("approval").style.display = "none";
    };

    function appendMessage(role, text) {
      const div = document.createElement("div");
      div.textContent = `[${role}] ${text}`;
      messages.appendChild(div);
      messages.scrollTop = messages.scrollHeight;
    }

    function updateLastAssistantMessage(text) {
      let last = messages.querySelector(".assistant-streaming");
      if (!last) {
        last = document.createElement("div");
        last.className = "assistant-streaming";
        messages.appendChild(last);
      }
      last.textContent = `[assistant] ${text}`;
      messages.scrollTop = messages.scrollHeight;
    }
  </script>
</body>
</html>
```

---

## 14. Advanced Features

### 14.1 Pre-warming with `startup()`

The first `query()` call is slow because it needs to spawn and initialize the Claude Code subprocess. Use `startup()` at server boot time to pre-warm it:

```typescript
import { startup, query } from "@anthropic-ai/claude-agent-sdk";

// Pre-warm at server start — returns a WarmQuery handle
const warm = await startup({ options: { pathToClaudeCodeExecutable: "claude", env: process.env } });

// Use warm.query() for the first session (~20x faster than standalone query())
const firstSession = warm.query(promptQueue);

// Subsequent sessions use standalone query()
const secondSession = query({ prompt: promptQueue2, options: { ... } });
```

> **Important:** `startup()` returns a `WarmQuery` — it's NOT fire-and-forget. You must use `warmQuery.query()` for the pre-warmed session. Requires Node.js >= 20 for `AsyncDisposable` support.

### 14.2 Probing Account Status Without Using Tokens

```typescript
import { query } from "@anthropic-ai/claude-agent-sdk";

async function probeClaudeStatus(claudePath = "claude") {
  const abort = new AbortController();
  const q = query({
    prompt: ".",
    options: {
      persistSession: false,
      pathToClaudeCodeExecutable: claudePath,
      abortController: abort,
      maxTurns: 0,              // Don't actually run any turns
      allowedTools: [],         // No tools
      settingSources: ["user", "project", "local"],
      stderr: () => {},
    },
  });

  const init = await q.initializationResult();
  abort.abort();  // Immediately abort — no API tokens consumed

  return {
    subscriptionType: init.account?.subscriptionType,
    commands: init.commands,
  };
}
```

### 14.3 Changing Model Mid-Session

```typescript
async function switchModel(sessionId: string, model: string) {
  const session = sessions.get(sessionId);
  if (!session) return;
  await session.runtime.setModel(model);
}
```

### 14.4 Changing Permission Mode Mid-Session

```typescript
async function switchPermissionMode(sessionId: string, mode: PermissionMode) {
  const session = sessions.get(sessionId);
  if (!session) return;
  await session.runtime.setPermissionMode(mode);
}
```

### 14.5 Sub-Agent Task Tracking

When Claude spawns sub-agents, you receive `system` messages with subtypes `task_started`, `task_progress`, and `task_notification`. Each has a `task_id` to correlate events:

```typescript
case "system":
  if (message.subtype === "task_started") {
    broadcast(session, {
      type: "task.started",
      taskId: message.message.task_id,
      description: message.message.description,
      taskType: message.message.task_type,
    });
  } else if (message.subtype === "task_progress") {
    broadcast(session, {
      type: "task.progress",
      taskId: message.message.task_id,
      description: message.message.description,
      summary: message.message.summary,
      lastToolName: message.message.last_tool_name,
    });
  } else if (message.subtype === "task_notification") {
    broadcast(session, {
      type: "task.completed",
      taskId: message.message.task_id,
      status: message.message.status,
      summary: message.message.summary,
    });
  }
  break;
```

### 14.6 Hook Events

Claude Code hooks fire `hook_started`, `hook_progress`, and `hook_response` system messages:

```typescript
// hook_started:  { hook_id, hook_name, hook_event }
// hook_progress: { hook_id, output?, stdout?, stderr? }
// hook_response: { hook_id, outcome, output?, stdout?, stderr?, exit_code? }
```

### 14.7 Context Compaction Events

When the context window fills up and Claude compacts it:

```typescript
// system subtype "status" with status: "compacting" — agent is compressing context
// system subtype "status" with status: "active"     — compaction done, back to work
// system subtype "compact_boundary"                  — marks the compaction boundary
```

### 14.8 Token Usage Tracking

The `result` message includes cumulative usage:

```typescript
if (message.type === "result" && message.usage) {
  console.log(`Input tokens: ${message.usage.input_tokens}`);
  console.log(`Output tokens: ${message.usage.output_tokens}`);
  console.log(`Cache read: ${message.usage.cache_read_input_tokens}`);
  console.log(`Total cost: $${message.total_cost_usd}`);
}
```

### 14.9 Custom Tools Per Session

Every option passed to `query()` is per-session — you can configure completely different tools, agents, MCP servers, and hooks for each individual session:

```typescript
// Session with a custom MCP server
const runtime = query({
  prompt: promptQueue,
  options: {
    cwd,
    pathToClaudeCodeExecutable: "claude",
    env: process.env,

    // MCP servers — loaded only for this session
    mcpServers: {
      "my-db-tools": {
        type: "stdio",
        command: "node",
        args: ["/path/to/mcp-server.js"],
        env: { DB_URL: "postgresql://..." },
      },
    },

    // Restrict which tools the agent can use
    allowedTools: ["Read", "Write", "Bash", "my-db-tools:query"],
    // or blacklist specific tools:
    disallowedTools: ["WebSearch", "WebFetch"],

    // Custom agent definitions for this session
    agents: {
      "code-reviewer": {
        description: "Reviews code changes",
        prompt: "You are a code reviewer...",
      },
    },

    // Lifecycle hooks — run on specific agent events
    hooks: {
      PreToolUse: [{ matcher: { tool_name: "Bash" }, callback: logBashCommands }],
      PostToolUse: [{ matcher: {}, callback: trackToolUsage }],
    },

    // Callback when agent wants to elicit user input outside normal turns
    onElicitation: async (message) => {
      // Prompt user and return their response
      return { action: "proceed", userInput: await askUser(message) };
    },
  },
});
```

**Key point:** `mcpServers`, `tools`, `agents`, `hooks`, `allowedTools`, `disallowedTools` — all are per-`query()` call. There's no global configuration; each session is fully independent.

### 14.9a Custom System Prompt Per Session

To inject a custom system prompt, define a custom agent via `agents` and select it with `agent`:

```typescript
const runtime = query({
  prompt: promptQueue,
  options: {
    cwd,
    pathToClaudeCodeExecutable: "claude",
    env: process.env,

    // Define a custom agent with your system prompt
    agents: {
      "my-assistant": {
        description: "A helpful coding assistant",
        prompt: "You are a senior engineer. Always explain your reasoning step by step. Prefer functional patterns over imperative ones.",
      },
    },
    agent: "my-assistant",  // Select it for this session
  },
});
```

This is per-session — each `query()` call can use a different agent definition with a different system prompt. The `prompt` field in the agent definition acts as the system prompt that the Claude Code subprocess prepends to the conversation.

### 14.10 Session Browser (listSessions + getSessionMessages)

```typescript
import { listSessions, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";

// Fetch all persisted sessions for the session browser UI
async function getSessionList() {
  const persisted = await listSessions({ limit: 100 });
  // Merge with in-memory active sessions
  const activeIds = new Set(sessions.keys());
  return [
    ...Array.from(sessions.values()).map(s => ({ session_id: s.id, active: true })),
    ...persisted
      .filter(p => !activeIds.has(p.session_id))
      .map(p => ({ ...p, active: false })),
  ];
}

// Load history when user selects a session
async function loadSessionHistory(sessionId: string) {
  const messages = await getSessionMessages(sessionId);
  return messages.filter(m => m.role === "user" || m.role === "assistant");
}

// Resume a session the user selected
function resumeSession(sessionId: string, cwd: string) {
  return query({
    prompt: new PromptQueue(),
    options: {
      cwd,
      resume: sessionId,  // Do NOT also pass sessionId here (would need forkSession: true)
      pathToClaudeCodeExecutable: "claude",
      env: process.env,
    },
  });
}
```

### 14.12 Streaming vs Buffered Mode

t3code supports two streaming modes:
- **Streaming**: Forward every text delta immediately (low latency, more messages)
- **Buffered**: Accumulate text and flush at natural pause points (fewer messages, slight delay)

For a chat UI, streaming mode is recommended. The SDK's `includePartialMessages: true` option enables this.

---

## 15. Gotchas & Production Considerations

### 15.1 The User's `claude` CLI Must Be Installed and Authenticated

Your app delegates to the user's own `claude` CLI. If it's not installed or not authenticated, your app can't work. Handle this gracefully:

- On startup, use `execFileSync(claudePath, ["--version"])` to check it exists (use `execFileSync`, not `execSync` with template literals — the latter is command injection)
- Run `execFileSync(claudePath, ["auth", "status"])` to check authentication
- If either fails, show the user clear instructions: "Install Claude Code from https://claude.ai/code and run `claude auth login`"

t3code does exactly this — see `ClaudeProvider.ts` where it runs both checks and surfaces the status to the UI.

### 15.1a Node.js >= 20 Required

The SDK requires **Node.js >= 20**. Node 18 is the minimum for basic `query()` usage, but `startup()` / `WarmQuery` use `AsyncDisposable` / `Symbol.asyncDispose` which requires Node >= 20. If you run on Node 10 or 16, you'll get "Object not disposable" errors at runtime. Use `n exec 20.12.2 node ...` or ensure your environment is Node 20+ before deploying.

### 15.2 Always Set the `stderr` Callback

The SDK subprocess writes errors and warnings to stderr. By default these are invisible. Always set `stderr` to at least log them:

```typescript
query({
  prompt: promptQueue,
  options: {
    stderr: (data) => console.error('[claude stderr]', data),
    // ... other options
  },
});
```

### 15.3 `effort` Is Session-Creation-Only

The `effort` option (`"low"` / `"medium"` / `"high"` / `"xhigh"` / `"max"`) can only be set when creating a session. There is no `runtime.setEffort()` method — unlike `setModel()`, `setPermissionMode()`, and `applyFlagSettings()` which can be changed mid-session. If your UI has an effort selector, disable it after session creation. Note: `"xhigh"` is only available with Opus 4.7+.

### 15.4 One Session = One Process

Each `query()` call spawns a separate `claude` process. Be mindful of resource usage if running many concurrent sessions. t3code runs one session per thread.

### 15.5 Session Persistence

Claude Code saves sessions to `~/.claude/sessions/` by default. Set `persistSession: false` for ephemeral sessions. Sessions can grow large over time.

### 15.6 The `canUseTool` Callback Blocks the Agent

The `canUseTool` callback is synchronous from the agent's perspective — the agent is paused until you return a `PermissionResult`. If the user disconnects without responding, the agent hangs. Always implement a timeout or abort handler:

```typescript
canUseTool: (toolName, toolInput, opts) => {
  return new Promise((resolve) => {
    let resolved = false;
    const done = (result) => {
      if (resolved) return; // Guard against double-resolve (timeout + abort)
      resolved = true;
      clearTimeout(timeout);
      pendingApprovals.delete(requestId);
      resolve(result);
    };

    const timeout = setTimeout(() => {
      done({ behavior: "deny", message: "Approval timeout" });
    }, 300_000); // 5 minute timeout

    opts.signal.addEventListener("abort", () => {
      done({ behavior: "deny", message: "Aborted" });
    }, { once: true });

    // ... register pending approval for WebSocket response
    pendingApprovals.set(requestId, done);
  });
}
```

### 15.7 Graceful Shutdown

When your server shuts down, close all sessions properly:

```typescript
process.on("SIGTERM", () => {
  for (const [id, session] of sessions) {
    session.queue.close();
    session.runtime.return(undefined).catch(() => {});  // AsyncGenerator.return(), NOT .close()
  }
  wss.close();
});
```

### 15.8 Resume + sessionId = Error (Without `forkSession: true`)

Passing both `resume` and `sessionId` in options without also setting `forkSession: true` causes the SDK to exit with error code 1. The correct patterns are:

```typescript
// ✅ Resume into the same session (SDK assigns a new tracking ID automatically)
{ resume: prevSessionId }

// ✅ Fork: resume but start a new divergent session with a specific ID
{ resume: prevSessionId, sessionId: newId, forkSession: true }

// ❌ This crashes with exit code 1:
{ resume: prevSessionId, sessionId: newId }  // Missing forkSession: true
```

When you resume a session, the SDK may assign a new `session_id`. Use the `session_id` from the `system/init` message as the canonical identifier, not the one you passed in `options.sessionId`.

### 15.9 Working Directory Matters

The `cwd` option determines which project the agent operates on. Tool calls (file reads, shell commands) are scoped to this directory. The agent reads `CLAUDE.md` files from this directory for project context.

### 15.10 Multi-Client Support

Multiple browser tabs can connect to the same session. t3code handles this with a `Set<WebSocket>` per session (shown in the minimal implementation above). All connected clients receive the same broadcast events.

### 15.11 Rate Limits

The SDK emits `rate_limit_event` messages with a `rate_limit_info` object. These arrive even when the request is allowed (status tracking), not just when rate-limited:

```typescript
if (message.type === "rate_limit_event") {
  const info = message.rate_limit_info;
  // Destructure to avoid duplicate `type` key in broadcast
  const { type: _t, ...rest } = message;
  broadcast(session, { type: "rate_limit", ...rest });

  // Show UI feedback only when actually limited
  if (info.status === "limited") {
    const resetsIn = Math.max(0, info.resetsAt - Math.floor(Date.now() / 1000));
    broadcast(session, {
      type: "rate_limit.warning",
      message: `Rate limited. Resets in ${Math.ceil(resetsIn / 60)} minutes.`,
      resetsAt: info.resetsAt,
      isUsingOverage: info.isUsingOverage,
    });
  }
}
```

The `rate_limit_info` shape varies — five-hour limits include `rateLimitType`, `overageStatus`, and `overageResetsAt`; simpler checks only have `status`, `resetsAt`, and `isUsingOverage`. See the RateLimitInfo type in [Section 4](#4-sdk-api-reference).

### 15.12 Auth Status

The SDK may need to re-authenticate during a session. Handle `auth_status` messages:

```typescript
if (message.type === "auth_status") {
  if (message.isAuthenticating) {
    broadcast(session, { type: "auth.authenticating", output: message.output });
  } else if (message.error) {
    broadcast(session, { type: "auth.error", error: message.error });
  }
}
```

### 15.13 Error Handling for `query()` Itself

The `query()` call can throw if the CLI binary is not found or fails to start:

```typescript
try {
  const runtime = query({ prompt, options });
} catch (err) {
  // CLI not found, permission denied, etc.
  ws.send(JSON.stringify({ type: "error", message: `Failed to start Claude: ${err}` }));
}
```

### 15.14 Version Compatibility

If using the bundled binary (default), the SDK and binary are always in sync. If using `pathToClaudeCodeExecutable` to point to a separate CLI, ensure the CLI version is compatible with the SDK version. Major version mismatches may cause NDJSON protocol errors.

### 15.15 The SDK Spawns a Real Process

Each `query()` call spawns a real OS process (the Claude Code binary). This has implications:
- The subprocess runs on the same machine as your server — it has filesystem access, can run shell commands, etc.
- The subprocess uses the machine's CPU, memory, and network to call the Anthropic API
- The `env` option controls what environment variables the subprocess sees (including API keys)
- If you're hosting this, the subprocess runs with your server's permissions — sandbox appropriately

### 15.16 Platform Support

Since you're using the user's own `claude` CLI (not the SDK's bundled binary), platform support depends on where the `claude` CLI is available: macOS, Linux, and Windows. The SDK's bundled binaries only cover macOS (arm64, x64), Linux (x64), and Windows (x64), but since you're not using them this doesn't matter — as long as the user has `claude` installed, your app works.

---

## Summary

| Concept | How It Works |
|---------|-------------|
| **SDK** | `@anthropic-ai/claude-agent-sdk` — publicly available npm package |
| **How it works internally** | Spawns user's `claude` CLI as a child subprocess, communicates via NDJSON stdin/stdout |
| **Auth** | User's own `claude` CLI auth — your app doesn't handle auth, user already ran `claude auth login` |
| **Cost to you** | Zero — the user's own subscription/API key is used |
| **Key option** | `pathToClaudeCodeExecutable: "claude"` — points to user's installed CLI |
| **Pre-warming** | `const warm = await startup()` then `warm.query(prompt)` for ~20x faster first session (Node >= 20) |
| **Core function** | `query({ prompt, options })` — returns `Query` (AsyncGenerator + control methods) |
| **Session listing** | `listSessions({ dir?, limit? })` — lists all persisted sessions from disk |
| **Session messages** | `getSessionMessages(sessionId, { dir? })` — loads message history from a persisted session |
| **Fork** | Use `resume` + `resumeSessionAt` + `forkSession: true` in options |
| **Prompt input** | `AsyncIterable<SDKUserMessage>` backed by a queue you push into |
| **Response streaming** | `for await (const msg of runtime)` — iterate SDK messages |
| **Tool approval** | `canUseTool` callback — return a Promise that resolves when user responds |
| **Session resume** | Pass `resume: previousSessionId` in options |
| **Interrupt** | `runtime.interrupt()` — stops current turn, keeps session alive |
| **Stop** | `runtime.return(undefined)` + `queue.close()` — kills the session |
| **Transport to browser** | WebSocket (bidirectional) or SSE + REST (simpler) |
| **What you DON'T need** | Direct Anthropic API calls, custom tool definitions, context management — the subprocess handles all of this |

The key insight: **Claude Code is the runtime, the SDK is a thin wrapper that spawns it as a subprocess, and your server is an adapter** that bridges the SDK's async-iterable interface to your frontend's WebSocket connection. The subprocess handles all the hard parts — API calls, tool execution, context management, session persistence. Keep your adapter thin.
