SKILL.md

  1---
  2name: claude-headless
  3description: Build custom UIs on top of Claude Code's headless mode. Covers spawning, NDJSON protocol, permission hooks, and session management. Use when building a desktop app, TUI, web UI, or any custom interface that wraps Claude Code as a subprocess.
  4---
  5
  6# Claude Headless
  7
  8Build custom UIs and applications on top of Claude Code by running it as a headless subprocess. Claude Code exposes a bidirectional NDJSON protocol over stdin/stdout that gives you full control over prompts, streaming responses, tool approvals, and session continuity.
  9
 10For complete event type catalog, read `references/event-types.md`.
 11For working code examples in Go and TypeScript, read `references/code-examples.md`.
 12
 13## Architecture Overview
 14
 15```
 16Your App (any language/framework)
 17  |
 18  ├── spawn: claude -p --input-format stream-json --output-format stream-json --verbose
 19  |
 20  ├── stdin  → write NDJSON messages (prompts, permission responses)
 21  ├── stdout ← read NDJSON events (text chunks, tool calls, results)
 22  └── stderr ← diagnostic logs (not structured, for debugging only)
 23```
 24
 25Claude Code runs as a child process. You write JSON lines to stdin, read JSON lines from stdout. No API key needed - it uses the existing OAuth login from `claude login`. Same subscription limits as the interactive CLI.
 26
 27## Spawning Claude in Headless Mode
 28
 29### Required Flags
 30
 31```
 32claude -p \
 33  --input-format stream-json \
 34  --output-format stream-json \
 35  --verbose \
 36  --include-partial-messages
 37```
 38
 39| Flag | Purpose |
 40|------|---------|
 41| `-p` | Print mode - non-interactive, reads from stdin |
 42| `--input-format stream-json` | Accept NDJSON on stdin (bidirectional) |
 43| `--output-format stream-json` | Emit NDJSON on stdout |
 44| `--verbose` | Include streaming events (content deltas, tool call updates) |
 45| `--include-partial-messages` | Emit partial content block events during streaming |
 46
 47### Optional Flags
 48
 49| Flag | Purpose |
 50|------|---------|
 51| `--resume <session-id>` | Continue an existing session |
 52| `--model <model>` | Choose model (e.g. `claude-sonnet-4-20250514`) |
 53| `--permission-mode default` | Use default permission behavior |
 54| `--allowedTools <tools>` | Comma-separated list of pre-approved tools |
 55| `--settings <path>` | Path to settings JSON with hook config |
 56| `--system-prompt <text>` | Replace the default system prompt entirely |
 57| `--append-system-prompt <text>` | Append to the default system prompt (additive, can coexist with `--system-prompt`) |
 58| `--max-turns <n>` | Limit number of agentic turns |
 59| `--max-budget-usd <n>` | Set spending cap per run |
 60| `--add-dir <path>` | Add extra directories to context (repeatable) |
 61
 62### Stdio Config
 63
 64Spawn with all three pipes: `stdin`, `stdout`, `stderr` as `pipe`. Stdin must stay open for follow-up messages. Set stdout encoding to UTF-8.
 65
 66### Environment
 67
 68Delete the `CLAUDECODE` env var if it exists in your process - it interferes with subprocess spawning. Ensure the `claude` binary is on `PATH` or use an absolute path.
 69
 70## NDJSON Input Protocol (stdin)
 71
 72### Sending a Prompt
 73
 74Write a single JSON line to stdin:
 75
 76```json
 77{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Your prompt here"}]}}
 78```
 79
 80**Important:** append `\n` after each JSON object. Stdin stays open - do not close it after writing. The process accepts multiple messages over its lifetime.
 81
 82### Content Types
 83
 84Text message:
 85```json
 86{
 87  "type": "user",
 88  "message": {
 89    "role": "user",
 90    "content": [{"type": "text", "text": "Explain this code"}]
 91  }
 92}
 93```
 94
 95The content array follows the Anthropic messages API format. Each element has a `type` field.
 96
 97### Permission Response
 98
 99When Claude requests tool approval and you're using the stdin-based permission flow (not HTTP hooks):
100
101```json
102{
103  "type": "permission_response",
104  "question_id": "the-question-id-from-the-event",
105  "option_id": "allow"
106}
107```
108
109Valid option IDs: `allow`, `allow-session`, `deny`. The `question_id` comes from the `permission_request` event.
110
111### Follow-up Messages
112
113Write additional user messages to stdin at any time. Claude processes them sequentially. After receiving a `result` event, close stdin to trigger a clean process exit.
114
115## NDJSON Output Protocol (stdout)
116
117Every line on stdout is a JSON object with a `type` field. Events arrive in this lifecycle order:
118
119```
120system (init) -> stream_event* -> assistant -> result
121                      ^                |
122                      |   (tool loop)  |
123                      +----------------+
124```
125
126### Event Lifecycle
127
1281. **`system`** (subtype `init`) - first event, contains session metadata
1292. **`stream_event`** - streaming content: text deltas, tool call starts/updates/stops
1303. **`assistant`** - assembled message with all content blocks (after streaming completes)
1314. **`result`** - final event, contains cost/usage/session_id
132
133Between steps 2-3, tool calls may trigger `permission_request` events (if using stdin-based permissions) or HTTP hook requests (if using a hook server).
134
135Rate limits produce `rate_limit_event` at any point.
136
137### Parsing Strategy
138
139Buffer incoming stdout data. Split on `\n`. Parse each non-empty line as JSON. Handle incomplete lines by keeping a buffer of the trailing fragment.
140
141```
142buffer += chunk
143lines = buffer.split('\n')
144buffer = lines.pop()  // keep incomplete trailing line
145for each line in lines:
146    if line.trim() is empty: skip
147    event = JSON.parse(line.trim())
148    handle(event)
149```
150
151On stream end, flush the buffer (parse any remaining content).
152
153### Detecting Completion
154
155The `result` event signals the run is complete. After receiving it, close stdin to trigger process exit. The process stays alive in `stream-json` input mode waiting for more input - closing stdin is what triggers the clean shutdown.
156
157```json
158{"type":"result","subtype":"success","result":"...","session_id":"...","total_cost_usd":0.003,...}
159```
160
161Check `is_error` and `subtype` on the result event. If `is_error` is true or `subtype` is `"error"`, the run failed.
162
163## Permission Hook Server
164
165For production UIs, use an HTTP-based PreToolUse hook instead of stdin-based permission flow. This gives you a proper request/response cycle with timeouts and scoped approvals.
166
167### How It Works
168
1691. Start a local HTTP server before spawning Claude
1702. Generate a per-run settings JSON file pointing Claude to your hook URL
1713. Pass the settings file via `--settings <path>`
1724. When Claude wants to use a tool, it POSTs to your hook URL
1735. Your server returns allow/deny
1746. Claude proceeds or skips the tool
175
176### Settings File Format
177
178```json
179{
180  "hooks": {
181    "PreToolUse": [
182      {
183        "matcher": "^(Bash|Edit|Write|MultiEdit|mcp__.*)$",
184        "hooks": [
185          {
186            "type": "http",
187            "url": "http://127.0.0.1:19836/hook/pre-tool-use/<app-secret>/<run-token>",
188            "timeout": 300
189          }
190        ]
191      }
192    ]
193  }
194}
195```
196
197The `matcher` is a regex against tool names. Only matched tools trigger the hook - unmatched tools need `--allowedTools` to run.
198
199**Security pattern:** embed a per-launch app secret and per-run token in the URL path. Validate both on every request. This prevents local spoofing and cross-run confusion.
200
201**File lifecycle:** write the settings file to a temp directory with restrictive permissions (0o600), clean it up when the run ends.
202
203### Hook Request (POST body from Claude)
204
205```json
206{
207  "session_id": "abc-123",
208  "hook_event_name": "PreToolUse",
209  "tool_name": "Bash",
210  "tool_input": {"command": "rm -rf /tmp/test"},
211  "tool_use_id": "toolu_xyz",
212  "cwd": "/Users/me/project",
213  "permission_mode": "default",
214  "transcript_path": "/path/to/transcript.jsonl"
215}
216```
217
218### Hook Response (your server returns)
219
220Allow:
221```json
222{
223  "hookSpecificOutput": {
224    "hookEventName": "PreToolUse",
225    "permissionDecision": "allow",
226    "permissionDecisionReason": "Approved by user"
227  }
228}
229```
230
231Deny:
232```json
233{
234  "hookSpecificOutput": {
235    "hookEventName": "PreToolUse",
236    "permissionDecision": "deny",
237    "permissionDecisionReason": "User denied"
238  }
239}
240```
241
242### Tool Safety Tiers
243
244Split tools into safe (auto-approve) and dangerous (require approval):
245
246**Safe tools** (pass via `--allowedTools`):
247`Read`, `Glob`, `Grep`, `LS`, `TodoRead`, `TodoWrite`, `Agent`, `Task`, `TaskOutput`, `Notebook`, `WebSearch`, `WebFetch`
248
249**Dangerous tools** (route through hook server):
250`Bash`, `Edit`, `Write`, `MultiEdit`, and any `mcp__*` tools
251
252You can additionally auto-approve read-only Bash commands by inspecting `tool_input.command` before prompting the user.
253
254### Timeout Behavior
255
256The hook has a `timeout` field in seconds (300 = 5 minutes). If your server doesn't respond in time, Claude treats it as a denial. Always deny-by-default on every failure path (parse errors, invalid tokens, timeouts).
257
258### Scoped Approvals
259
260Track user decisions to reduce permission fatigue:
261
262- **Session-scoped:** user approves "Edit" once, auto-allow for the rest of the session. Key: `session:<id>:tool:<name>`
263- **Domain-scoped:** for WebFetch, approve a domain once. Key: `session:<id>:webfetch:<domain>`
264- **Per-command:** Bash commands are too diverse for blanket approval - review each individually
265
266## Session Management
267
268### Session IDs
269
270The `system` init event returns a `session_id`. Store it. Pass it back via `--resume <session-id>` on subsequent runs to continue the conversation.
271
272### Multiple Concurrent Sessions
273
274Each session is a separate `claude -p` child process. You can run many in parallel. Track each by a unique request ID mapped to its process handle.
275
276### Session Lifecycle
277
278```
279idle -> connecting -> running -> completed
280                        |           |
281                        v           v
282                      failed      idle (new prompt)
283                        |
284                        v
285                       dead (unrecoverable)
286```
287
288- **connecting:** process spawned, waiting for `system` init event
289- **running:** init received, streaming in progress
290- **completed:** `result` event received with `subtype: "success"`
291- **failed:** non-zero exit, SIGINT/SIGKILL, or error result
292- **dead:** process error (binary not found, spawn failure)
293
294### Tab Pattern
295
296For multi-tab UIs, maintain a registry mapping tab IDs to session state:
297
298```
299Tab Registry:
300  tabId -> {
301    claudeSessionId: string | null,
302    status: TabStatus,
303    activeRequestId: string | null,
304    promptCount: number,
305  }
306```
307
308Queue prompts if a tab already has an active run. Process the queue when the current run completes.
309
310### Cancellation
311
312Send SIGINT to the child process. If it hasn't exited after 5 seconds, send SIGKILL.
313
314## Model Routing
315
316Pass `--model <model-id>` when spawning. To switch models mid-conversation, start a new process with `--resume <session-id> --model <new-model>`. The session context carries over.
317
318## Common Patterns
319
320### Streaming Text to UI
321
322Listen for `stream_event` events where the inner event type is `content_block_delta` with `delta.type === "text_delta"`. Append `delta.text` to your display buffer.
323
324### Tracking Tool Calls
325
3261. `content_block_start` with `content_block.type === "tool_use"` - tool call begins, extract `name` and `id`
3272. `content_block_delta` with `delta.type === "input_json_delta"` - partial tool input JSON arrives
3283. `content_block_stop` - tool call input is complete
329
330The `assistant` event arrives after all content blocks, containing the fully assembled message with all tool calls and their complete inputs.
331
332### Idempotent Request IDs
333
334Use unique request IDs for each prompt submission. If a duplicate ID is submitted while inflight, return the existing promise instead of spawning a new process. This prevents double-submissions from UI race conditions.
335
336### Request Queuing
337
338If a tab already has an active run, queue the new request. Process the queue (FIFO) when the current run's exit event fires. Set a max queue depth (32 is reasonable) and reject with backpressure when full.
339
340### Warm-up Init
341
342To pre-populate session metadata (available tools, model, MCP servers) without showing a visible message, fire a minimal prompt like `"hi"` with `--max-turns 1` at tab creation. Suppress all events except the `session_init` from this request.
343
344## What NOT to Do
345
3461. **Don't close stdin after the first prompt.** The process stays alive for follow-up messages. Only close stdin after receiving the `result` event to trigger clean exit.
347
3482. **Don't parse stderr as structured data.** It contains diagnostic logs, not NDJSON. Read it for debugging only.
349
3503. **Don't use `--output-format json`** (non-streaming). You get a single JSON blob at the end with no intermediate events. Always use `stream-json`.
351
3524. **Don't skip `--verbose` and `--include-partial-messages`.** Without these, you miss streaming content deltas and tool call updates. Your UI will appear frozen until the full response completes.
353
3545. **Don't auto-approve all tools without a hook server.** If you pass every tool in `--allowedTools`, Claude will execute destructive operations (file writes, shell commands) without user consent.
355
3566. **Don't ignore the `CLAUDECODE` env var.** If your app is itself running inside Claude Code, this var will be set and can interfere with subprocess spawning. Delete it from the child's environment.
357
3587. **Don't forget request ID idempotency.** UI double-clicks and network retries can cause duplicate submissions. Always check if a request ID is already inflight or queued before spawning.