1# Code Examples
2
3Working code for spawning Claude headless, parsing NDJSON, and handling permissions. TypeScript and Go.
4
5## TypeScript: Spawn and Parse
6
7### NDJSON Stream Parser
8
9Buffers incoming data and emits complete JSON objects line by line.
10
11```typescript
12import { Readable } from 'stream'
13import { EventEmitter } from 'events'
14
15interface ClaudeEvent {
16 type: string
17 [key: string]: unknown
18}
19
20class StreamParser extends EventEmitter {
21 private buffer = ''
22
23 feed(chunk: string): void {
24 this.buffer += chunk
25 const lines = this.buffer.split('\n')
26 this.buffer = lines.pop() || '' // keep incomplete trailing line
27
28 for (const line of lines) {
29 const trimmed = line.trim()
30 if (!trimmed) continue
31 try {
32 const parsed = JSON.parse(trimmed) as ClaudeEvent
33 this.emit('event', parsed)
34 } catch {
35 this.emit('parse-error', trimmed)
36 }
37 }
38 }
39
40 flush(): void {
41 const trimmed = this.buffer.trim()
42 if (trimmed) {
43 try {
44 this.emit('event', JSON.parse(trimmed))
45 } catch {
46 this.emit('parse-error', trimmed)
47 }
48 }
49 this.buffer = ''
50 }
51
52 static fromStream(stream: Readable): StreamParser {
53 const parser = new StreamParser()
54 stream.setEncoding('utf-8')
55 stream.on('data', (chunk: string) => parser.feed(chunk))
56 stream.on('end', () => parser.flush())
57 return parser
58 }
59}
60```
61
62### Spawning Claude
63
64```typescript
65import { spawn, ChildProcess } from 'child_process'
66
67interface RunOptions {
68 prompt: string
69 cwd: string
70 sessionId?: string
71 model?: string
72 hookSettingsPath?: string
73 allowedTools?: string[]
74 maxTurns?: number
75}
76
77function startRun(options: RunOptions): ChildProcess {
78 const args: string[] = [
79 '-p',
80 '--input-format', 'stream-json',
81 '--output-format', 'stream-json',
82 '--verbose',
83 '--include-partial-messages',
84 '--permission-mode', 'default',
85 ]
86
87 if (options.sessionId) {
88 args.push('--resume', options.sessionId)
89 }
90 if (options.model) {
91 args.push('--model', options.model)
92 }
93 if (options.hookSettingsPath) {
94 args.push('--settings', options.hookSettingsPath)
95 }
96 if (options.allowedTools?.length) {
97 args.push('--allowedTools', options.allowedTools.join(','))
98 }
99 if (options.maxTurns) {
100 args.push('--max-turns', String(options.maxTurns))
101 }
102
103 // Remove CLAUDECODE env var to avoid subprocess conflicts
104 const env = { ...process.env }
105 delete env.CLAUDECODE
106
107 const child = spawn('claude', args, {
108 stdio: ['pipe', 'pipe', 'pipe'],
109 cwd: options.cwd,
110 env,
111 })
112
113 // Write initial prompt
114 const userMessage = JSON.stringify({
115 type: 'user',
116 message: {
117 role: 'user',
118 content: [{ type: 'text', text: options.prompt }],
119 },
120 })
121 child.stdin!.write(userMessage + '\n')
122
123 return child
124}
125```
126
127### Full Lifecycle Example
128
129```typescript
130async function runPrompt(prompt: string, cwd: string): Promise<string> {
131 const child = startRun({ prompt, cwd })
132
133 const parser = StreamParser.fromStream(child.stdout!)
134 let sessionId: string | null = null
135 let resultText = ''
136 const textChunks: string[] = []
137
138 return new Promise((resolve, reject) => {
139 parser.on('event', (event: ClaudeEvent) => {
140 switch (event.type) {
141 case 'system':
142 if ((event as any).subtype === 'init') {
143 sessionId = (event as any).session_id
144 }
145 break
146
147 case 'stream_event': {
148 const sub = (event as any).event
149 if (sub?.type === 'content_block_delta' && sub.delta?.type === 'text_delta') {
150 textChunks.push(sub.delta.text)
151 process.stdout.write(sub.delta.text) // stream to terminal
152 }
153 break
154 }
155
156 case 'result':
157 resultText = (event as any).result || textChunks.join('')
158 // Close stdin to trigger clean exit
159 try { child.stdin?.end() } catch {}
160 break
161 }
162 })
163
164 child.on('close', (code) => {
165 if (code === 0) {
166 resolve(resultText)
167 } else {
168 reject(new Error(`claude exited with code ${code}`))
169 }
170 })
171
172 child.on('error', reject)
173 })
174}
175```
176
177## TypeScript: Permission Hook Server
178
179### Minimal HTTP Hook Server
180
181```typescript
182import { createServer, IncomingMessage, ServerResponse } from 'http'
183import { writeFileSync, mkdirSync, unlinkSync } from 'fs'
184import { tmpdir } from 'os'
185import { join } from 'path'
186import { randomUUID } from 'crypto'
187
188const PORT = 19836
189const APP_SECRET = randomUUID()
190
191interface HookRequest {
192 session_id: string
193 hook_event_name: string
194 tool_name: string
195 tool_input: Record<string, unknown>
196 tool_use_id: string
197 cwd: string
198}
199
200// Tools that need user approval
201const DANGEROUS_TOOLS = new Set(['Bash', 'Edit', 'Write', 'MultiEdit'])
202
203// Tools to auto-approve via --allowedTools
204const SAFE_TOOLS = [
205 'Read', 'Glob', 'Grep', 'LS',
206 'TodoRead', 'TodoWrite',
207 'Agent', 'Task', 'TaskOutput',
208 'Notebook', 'WebSearch', 'WebFetch',
209]
210
211function allowResponse(reason: string) {
212 return {
213 hookSpecificOutput: {
214 hookEventName: 'PreToolUse',
215 permissionDecision: 'allow',
216 permissionDecisionReason: reason,
217 },
218 }
219}
220
221function denyResponse(reason: string) {
222 return {
223 hookSpecificOutput: {
224 hookEventName: 'PreToolUse',
225 permissionDecision: 'deny',
226 permissionDecisionReason: reason,
227 },
228 }
229}
230
231// Your approval callback - wire this to your UI
232type ApprovalCallback = (tool: string, input: Record<string, unknown>) => Promise<boolean>
233
234function startHookServer(onApproval: ApprovalCallback) {
235 const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
236 if (req.method !== 'POST') {
237 res.writeHead(404)
238 res.end(JSON.stringify(denyResponse('Not found')))
239 return
240 }
241
242 // Validate URL path: /hook/pre-tool-use/<secret>/<token>
243 const segments = (req.url || '').split('/').filter(Boolean)
244 if (segments.length < 3 || segments[2] !== APP_SECRET) {
245 res.writeHead(403)
246 res.end(JSON.stringify(denyResponse('Invalid credentials')))
247 return
248 }
249
250 // Read body
251 let body = ''
252 for await (const chunk of req) body += chunk
253
254 const toolReq: HookRequest = JSON.parse(body)
255
256 // Auto-approve if not a dangerous tool (belt-and-suspenders with matcher)
257 if (!DANGEROUS_TOOLS.has(toolReq.tool_name)) {
258 res.writeHead(200, { 'Content-Type': 'application/json' })
259 res.end(JSON.stringify(allowResponse('Safe tool')))
260 return
261 }
262
263 // Ask user via your UI
264 const approved = await onApproval(toolReq.tool_name, toolReq.tool_input)
265
266 res.writeHead(200, { 'Content-Type': 'application/json' })
267 res.end(JSON.stringify(
268 approved
269 ? allowResponse('User approved')
270 : denyResponse('User denied')
271 ))
272 })
273
274 server.listen(PORT, '127.0.0.1')
275 return server
276}
277
278// Generate settings file for a run
279function generateSettingsFile(runToken: string): string {
280 const matcher = '^(Bash|Edit|Write|MultiEdit|mcp__.*)$'
281 const settings = {
282 hooks: {
283 PreToolUse: [{
284 matcher,
285 hooks: [{
286 type: 'http',
287 url: `http://127.0.0.1:${PORT}/hook/pre-tool-use/${APP_SECRET}/${runToken}`,
288 timeout: 300,
289 }],
290 }],
291 },
292 }
293
294 const dir = join(tmpdir(), 'my-app-hooks')
295 mkdirSync(dir, { recursive: true, mode: 0o700 })
296 const filePath = join(dir, `hook-${runToken}.json`)
297 writeFileSync(filePath, JSON.stringify(settings), { mode: 0o600 })
298 return filePath
299}
300```
301
302## Go: Spawn and Parse
303
304### NDJSON Parser
305
306```go
307package claude
308
309import (
310 "bufio"
311 "encoding/json"
312 "io"
313)
314
315// Event represents any NDJSON event from Claude's stdout.
316type Event struct {
317 Type string `json:"type"`
318 Subtype string `json:"subtype,omitempty"`
319 SessionID string `json:"session_id,omitempty"`
320 Raw json.RawMessage `json:"-"` // full original JSON
321}
322
323// StreamSubEvent is the nested event inside stream_event.
324type StreamSubEvent struct {
325 Type string `json:"type"`
326 Index int `json:"index,omitempty"`
327 ContentBlock ContentBlock `json:"content_block,omitempty"`
328 Delta Delta `json:"delta,omitempty"`
329}
330
331type ContentBlock struct {
332 Type string `json:"type"`
333 Text string `json:"text,omitempty"`
334 ID string `json:"id,omitempty"`
335 Name string `json:"name,omitempty"`
336}
337
338type Delta struct {
339 Type string `json:"type"`
340 Text string `json:"text,omitempty"`
341 PartialJSON string `json:"partial_json,omitempty"`
342}
343
344// StreamEvent is the full stream_event with its nested event.
345type StreamEvent struct {
346 Type string `json:"type"`
347 Event StreamSubEvent `json:"event"`
348 SessionID string `json:"session_id"`
349 ParentToolUseID *string `json:"parent_tool_use_id"`
350}
351
352// ResultEvent is the final event of a run.
353type ResultEvent struct {
354 Type string `json:"type"`
355 Subtype string `json:"subtype"`
356 IsError bool `json:"is_error"`
357 Result string `json:"result"`
358 SessionID string `json:"session_id"`
359 TotalCostUSD float64 `json:"total_cost_usd"`
360 DurationMs int `json:"duration_ms"`
361 NumTurns int `json:"num_turns"`
362}
363
364// ParseEvents reads NDJSON lines from a reader and sends parsed events to a channel.
365func ParseEvents(r io.Reader, events chan<- json.RawMessage, errs chan<- error) {
366 scanner := bufio.NewScanner(r)
367 // Increase buffer for large events
368 scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
369
370 for scanner.Scan() {
371 line := scanner.Bytes()
372 if len(line) == 0 {
373 continue
374 }
375 // Make a copy since scanner reuses the buffer
376 cp := make([]byte, len(line))
377 copy(cp, line)
378 events <- json.RawMessage(cp)
379 }
380
381 if err := scanner.Err(); err != nil {
382 errs <- err
383 }
384 close(events)
385}
386```
387
388### Spawning Claude
389
390```go
391package claude
392
393import (
394 "encoding/json"
395 "fmt"
396 "io"
397 "os"
398 "os/exec"
399 "strings"
400)
401
402type RunConfig struct {
403 Prompt string
404 Cwd string
405 SessionID string
406 Model string
407 SettingsPath string
408 AllowedTools []string
409 MaxTurns int
410}
411
412// UserMessage is the NDJSON input format for stdin.
413type UserMessage struct {
414 Type string `json:"type"`
415 Message MessagePayload `json:"message"`
416}
417
418type MessagePayload struct {
419 Role string `json:"role"`
420 Content []ContentPart `json:"content"`
421}
422
423type ContentPart struct {
424 Type string `json:"type"`
425 Text string `json:"text"`
426}
427
428// SpawnResult holds the started process and its I/O handles.
429type SpawnResult struct {
430 Cmd *exec.Cmd
431 Stdin io.WriteCloser
432 Stdout io.ReadCloser
433}
434
435func SpawnClaude(cfg RunConfig) (*SpawnResult, error) {
436 args := []string{
437 "-p",
438 "--input-format", "stream-json",
439 "--output-format", "stream-json",
440 "--verbose",
441 "--include-partial-messages",
442 "--permission-mode", "default",
443 }
444
445 if cfg.SessionID != "" {
446 args = append(args, "--resume", cfg.SessionID)
447 }
448 if cfg.Model != "" {
449 args = append(args, "--model", cfg.Model)
450 }
451 if cfg.SettingsPath != "" {
452 args = append(args, "--settings", cfg.SettingsPath)
453 }
454 if len(cfg.AllowedTools) > 0 {
455 args = append(args, "--allowedTools", strings.Join(cfg.AllowedTools, ","))
456 }
457 if cfg.MaxTurns > 0 {
458 args = append(args, "--max-turns", fmt.Sprintf("%d", cfg.MaxTurns))
459 }
460
461 cmd := exec.Command("claude", args...)
462 cmd.Dir = cfg.Cwd
463
464 // Clean env
465 env := os.Environ()
466 filtered := make([]string, 0, len(env))
467 for _, e := range env {
468 if !strings.HasPrefix(e, "CLAUDECODE=") {
469 filtered = append(filtered, e)
470 }
471 }
472 cmd.Env = filtered
473 cmd.Stderr = os.Stderr // or capture separately
474
475 // Get pipes before Start - cannot mix StdinPipe with cmd.Stdin assignment
476 stdin, err := cmd.StdinPipe()
477 if err != nil {
478 return nil, err
479 }
480 stdout, err := cmd.StdoutPipe()
481 if err != nil {
482 return nil, err
483 }
484
485 if err := cmd.Start(); err != nil {
486 return nil, err
487 }
488
489 return &SpawnResult{Cmd: cmd, Stdin: stdin, Stdout: stdout}, nil
490}
491
492// WritePrompt sends a user message to Claude's stdin.
493func WritePrompt(stdin io.WriteCloser, prompt string) error {
494 msg := UserMessage{
495 Type: "user",
496 Message: MessagePayload{
497 Role: "user",
498 Content: []ContentPart{
499 {Type: "text", Text: prompt},
500 },
501 },
502 }
503
504 data, err := json.Marshal(msg)
505 if err != nil {
506 return err
507 }
508
509 _, err = fmt.Fprintf(stdin, "%s\n", data)
510 return err
511}
512```
513
514### Bubbletea Integration Skeleton
515
516The key pattern: each `tea.Cmd` reads one event and returns it. Bubbletea calls `Update` with that message, then the model returns the next read command - creating a pull loop without goroutines racing against the program.
517
518```go
519package main
520
521import (
522 "bufio"
523 "encoding/json"
524 "fmt"
525 "io"
526 "os/exec"
527 "strings"
528
529 tea "github.com/charmbracelet/bubbletea"
530)
531
532type eventMsg struct{ raw json.RawMessage }
533type doneMsg struct{ err error }
534
535type model struct {
536 output strings.Builder
537 scanner *bufio.Scanner
538 stdin io.WriteCloser
539 done bool
540 err error
541}
542
543func (m model) Init() tea.Cmd {
544 cmd := exec.Command("claude", "-p",
545 "--input-format", "stream-json",
546 "--output-format", "stream-json",
547 "--verbose", "--include-partial-messages",
548 )
549 stdin, _ := cmd.StdinPipe()
550 stdout, _ := cmd.StdoutPipe()
551 if err := cmd.Start(); err != nil {
552 return func() tea.Msg { return doneMsg{err: err} }
553 }
554
555 prompt, _ := json.Marshal(map[string]any{
556 "type": "user",
557 "message": map[string]any{
558 "role": "user",
559 "content": []map[string]string{{"type": "text", "text": "Explain what a goroutine is"}},
560 },
561 })
562 fmt.Fprintf(stdin, "%s\n", prompt)
563
564 m.stdin = stdin
565 scanner := bufio.NewScanner(stdout)
566 scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
567 m.scanner = scanner
568
569 return readNext(scanner)
570}
571
572// readNext returns a tea.Cmd that blocks until the next NDJSON line arrives.
573func readNext(s *bufio.Scanner) tea.Cmd {
574 return func() tea.Msg {
575 for s.Scan() {
576 line := s.Bytes()
577 if len(line) == 0 {
578 continue
579 }
580 cp := make([]byte, len(line))
581 copy(cp, line)
582 return eventMsg{raw: cp}
583 }
584 return doneMsg{err: s.Err()}
585 }
586}
587
588func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
589 switch msg := msg.(type) {
590 case eventMsg:
591 var base struct {
592 Type string `json:"type"`
593 Event struct {
594 Type string `json:"type"`
595 Delta struct {
596 Type string `json:"type"`
597 Text string `json:"text"`
598 } `json:"delta"`
599 } `json:"event"`
600 }
601 json.Unmarshal(msg.raw, &base)
602 if base.Type == "stream_event" &&
603 base.Event.Type == "content_block_delta" &&
604 base.Event.Delta.Type == "text_delta" {
605 m.output.WriteString(base.Event.Delta.Text)
606 }
607 if base.Type == "result" {
608 m.done = true
609 m.stdin.Close()
610 }
611 return m, readNext(m.scanner)
612
613 case doneMsg:
614 m.done = true
615 m.err = msg.err
616 return m, tea.Quit
617
618 case tea.KeyMsg:
619 if msg.String() == "q" || msg.String() == "ctrl+c" {
620 return m, tea.Quit
621 }
622 }
623 return m, nil
624}
625
626func (m model) View() string {
627 status := "streaming..."
628 if m.done {
629 status = "done"
630 }
631 if m.err != nil {
632 return fmt.Sprintf("error: %v\n", m.err)
633 }
634 return fmt.Sprintf("[%s]\n\n%s\n\nPress q to quit.", status, m.output.String())
635}
636
637func main() {
638 p := tea.NewProgram(model{})
639 if _, err := p.Run(); err != nil {
640 fmt.Printf("error: %v\n", err)
641 }
642}
643```
644
645## Patterns
646
647### Cancellation (TypeScript)
648
649```typescript
650function cancelRun(child: ChildProcess): void {
651 child.kill('SIGINT')
652
653 // Fallback: SIGKILL after 5s if SIGINT didn't work
654 setTimeout(() => {
655 if (child.exitCode === null) {
656 child.kill('SIGKILL')
657 }
658 }, 5000)
659}
660```
661
662### Follow-up Message (TypeScript)
663
664```typescript
665function sendFollowUp(child: ChildProcess, text: string): void {
666 const msg = JSON.stringify({
667 type: 'user',
668 message: {
669 role: 'user',
670 content: [{ type: 'text', text }],
671 },
672 })
673 child.stdin!.write(msg + '\n')
674}
675```
676
677### Track Tool Calls (TypeScript)
678
679```typescript
680interface ToolCall {
681 id: string
682 name: string
683 inputFragments: string[]
684 complete: boolean
685}
686
687const activeTools = new Map<number, ToolCall>() // index -> tool
688
689function handleStreamEvent(event: any): void {
690 const sub = event.event
691 if (!sub) return
692
693 switch (sub.type) {
694 case 'content_block_start':
695 if (sub.content_block.type === 'tool_use') {
696 activeTools.set(sub.index, {
697 id: sub.content_block.id,
698 name: sub.content_block.name,
699 inputFragments: [],
700 complete: false,
701 })
702 }
703 break
704
705 case 'content_block_delta':
706 if (sub.delta.type === 'input_json_delta') {
707 const tool = activeTools.get(sub.index)
708 if (tool) {
709 tool.inputFragments.push(sub.delta.partial_json)
710 }
711 }
712 break
713
714 case 'content_block_stop': {
715 const tool = activeTools.get(sub.index)
716 if (tool) {
717 tool.complete = true
718 const fullInput = JSON.parse(tool.inputFragments.join(''))
719 console.log(`Tool ${tool.name}: ${JSON.stringify(fullInput)}`)
720 }
721 break
722 }
723 }
724}
725```
726
727### Diagnostic Ring Buffer (TypeScript)
728
729Keep a ring buffer of the last N stderr lines for error reporting.
730
731```typescript
732const MAX_LINES = 100
733
734class RingBuffer {
735 private lines: string[] = []
736
737 push(line: string): void {
738 this.lines.push(line)
739 if (this.lines.length > MAX_LINES) {
740 this.lines.shift()
741 }
742 }
743
744 tail(n: number): string[] {
745 return this.lines.slice(-n)
746 }
747}
748
749// Usage:
750const stderrBuf = new RingBuffer()
751child.stderr?.setEncoding('utf-8')
752child.stderr?.on('data', (data: string) => {
753 for (const line of data.split('\n').filter(l => l.trim())) {
754 stderrBuf.push(line)
755 }
756})
757
758// On error, include last 20 stderr lines in diagnostics
759child.on('close', (code) => {
760 if (code !== 0) {
761 console.error('Last stderr:', stderrBuf.tail(20))
762 }
763})
764```