code-examples.md

  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```