code-examples.md

Code Examples

Working code for spawning Claude headless, parsing NDJSON, and handling permissions. TypeScript and Go.

TypeScript: Spawn and Parse

NDJSON Stream Parser

Buffers incoming data and emits complete JSON objects line by line.

import { Readable } from 'stream'
import { EventEmitter } from 'events'

interface ClaudeEvent {
  type: string
  [key: string]: unknown
}

class StreamParser extends EventEmitter {
  private buffer = ''

  feed(chunk: string): void {
    this.buffer += chunk
    const lines = this.buffer.split('\n')
    this.buffer = lines.pop() || '' // keep incomplete trailing line

    for (const line of lines) {
      const trimmed = line.trim()
      if (!trimmed) continue
      try {
        const parsed = JSON.parse(trimmed) as ClaudeEvent
        this.emit('event', parsed)
      } catch {
        this.emit('parse-error', trimmed)
      }
    }
  }

  flush(): void {
    const trimmed = this.buffer.trim()
    if (trimmed) {
      try {
        this.emit('event', JSON.parse(trimmed))
      } catch {
        this.emit('parse-error', trimmed)
      }
    }
    this.buffer = ''
  }

  static fromStream(stream: Readable): StreamParser {
    const parser = new StreamParser()
    stream.setEncoding('utf-8')
    stream.on('data', (chunk: string) => parser.feed(chunk))
    stream.on('end', () => parser.flush())
    return parser
  }
}

Spawning Claude

import { spawn, ChildProcess } from 'child_process'

interface RunOptions {
  prompt: string
  cwd: string
  sessionId?: string
  model?: string
  hookSettingsPath?: string
  allowedTools?: string[]
  maxTurns?: number
}

function startRun(options: RunOptions): ChildProcess {
  const args: string[] = [
    '-p',
    '--input-format', 'stream-json',
    '--output-format', 'stream-json',
    '--verbose',
    '--include-partial-messages',
    '--permission-mode', 'default',
  ]

  if (options.sessionId) {
    args.push('--resume', options.sessionId)
  }
  if (options.model) {
    args.push('--model', options.model)
  }
  if (options.hookSettingsPath) {
    args.push('--settings', options.hookSettingsPath)
  }
  if (options.allowedTools?.length) {
    args.push('--allowedTools', options.allowedTools.join(','))
  }
  if (options.maxTurns) {
    args.push('--max-turns', String(options.maxTurns))
  }

  // Remove CLAUDECODE env var to avoid subprocess conflicts
  const env = { ...process.env }
  delete env.CLAUDECODE

  const child = spawn('claude', args, {
    stdio: ['pipe', 'pipe', 'pipe'],
    cwd: options.cwd,
    env,
  })

  // Write initial prompt
  const userMessage = JSON.stringify({
    type: 'user',
    message: {
      role: 'user',
      content: [{ type: 'text', text: options.prompt }],
    },
  })
  child.stdin!.write(userMessage + '\n')

  return child
}

Full Lifecycle Example

async function runPrompt(prompt: string, cwd: string): Promise<string> {
  const child = startRun({ prompt, cwd })

  const parser = StreamParser.fromStream(child.stdout!)
  let sessionId: string | null = null
  let resultText = ''
  const textChunks: string[] = []

  return new Promise((resolve, reject) => {
    parser.on('event', (event: ClaudeEvent) => {
      switch (event.type) {
        case 'system':
          if ((event as any).subtype === 'init') {
            sessionId = (event as any).session_id
          }
          break

        case 'stream_event': {
          const sub = (event as any).event
          if (sub?.type === 'content_block_delta' && sub.delta?.type === 'text_delta') {
            textChunks.push(sub.delta.text)
            process.stdout.write(sub.delta.text) // stream to terminal
          }
          break
        }

        case 'result':
          resultText = (event as any).result || textChunks.join('')
          // Close stdin to trigger clean exit
          try { child.stdin?.end() } catch {}
          break
      }
    })

    child.on('close', (code) => {
      if (code === 0) {
        resolve(resultText)
      } else {
        reject(new Error(`claude exited with code ${code}`))
      }
    })

    child.on('error', reject)
  })
}

TypeScript: Permission Hook Server

Minimal HTTP Hook Server

import { createServer, IncomingMessage, ServerResponse } from 'http'
import { writeFileSync, mkdirSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { randomUUID } from 'crypto'

const PORT = 19836
const APP_SECRET = randomUUID()

interface HookRequest {
  session_id: string
  hook_event_name: string
  tool_name: string
  tool_input: Record<string, unknown>
  tool_use_id: string
  cwd: string
}

// Tools that need user approval
const DANGEROUS_TOOLS = new Set(['Bash', 'Edit', 'Write', 'MultiEdit'])

// Tools to auto-approve via --allowedTools
const SAFE_TOOLS = [
  'Read', 'Glob', 'Grep', 'LS',
  'TodoRead', 'TodoWrite',
  'Agent', 'Task', 'TaskOutput',
  'Notebook', 'WebSearch', 'WebFetch',
]

function allowResponse(reason: string) {
  return {
    hookSpecificOutput: {
      hookEventName: 'PreToolUse',
      permissionDecision: 'allow',
      permissionDecisionReason: reason,
    },
  }
}

function denyResponse(reason: string) {
  return {
    hookSpecificOutput: {
      hookEventName: 'PreToolUse',
      permissionDecision: 'deny',
      permissionDecisionReason: reason,
    },
  }
}

// Your approval callback - wire this to your UI
type ApprovalCallback = (tool: string, input: Record<string, unknown>) => Promise<boolean>

function startHookServer(onApproval: ApprovalCallback) {
  const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
    if (req.method !== 'POST') {
      res.writeHead(404)
      res.end(JSON.stringify(denyResponse('Not found')))
      return
    }

    // Validate URL path: /hook/pre-tool-use/<secret>/<token>
    const segments = (req.url || '').split('/').filter(Boolean)
    if (segments.length < 3 || segments[2] !== APP_SECRET) {
      res.writeHead(403)
      res.end(JSON.stringify(denyResponse('Invalid credentials')))
      return
    }

    // Read body
    let body = ''
    for await (const chunk of req) body += chunk

    const toolReq: HookRequest = JSON.parse(body)

    // Auto-approve if not a dangerous tool (belt-and-suspenders with matcher)
    if (!DANGEROUS_TOOLS.has(toolReq.tool_name)) {
      res.writeHead(200, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify(allowResponse('Safe tool')))
      return
    }

    // Ask user via your UI
    const approved = await onApproval(toolReq.tool_name, toolReq.tool_input)

    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify(
      approved
        ? allowResponse('User approved')
        : denyResponse('User denied')
    ))
  })

  server.listen(PORT, '127.0.0.1')
  return server
}

// Generate settings file for a run
function generateSettingsFile(runToken: string): string {
  const matcher = '^(Bash|Edit|Write|MultiEdit|mcp__.*)$'
  const settings = {
    hooks: {
      PreToolUse: [{
        matcher,
        hooks: [{
          type: 'http',
          url: `http://127.0.0.1:${PORT}/hook/pre-tool-use/${APP_SECRET}/${runToken}`,
          timeout: 300,
        }],
      }],
    },
  }

  const dir = join(tmpdir(), 'my-app-hooks')
  mkdirSync(dir, { recursive: true, mode: 0o700 })
  const filePath = join(dir, `hook-${runToken}.json`)
  writeFileSync(filePath, JSON.stringify(settings), { mode: 0o600 })
  return filePath
}

Go: Spawn and Parse

NDJSON Parser

package claude

import (
    "bufio"
    "encoding/json"
    "io"
)

// Event represents any NDJSON event from Claude's stdout.
type Event struct {
    Type      string          `json:"type"`
    Subtype   string          `json:"subtype,omitempty"`
    SessionID string          `json:"session_id,omitempty"`
    Raw       json.RawMessage `json:"-"` // full original JSON
}

// StreamSubEvent is the nested event inside stream_event.
type StreamSubEvent struct {
    Type         string       `json:"type"`
    Index        int          `json:"index,omitempty"`
    ContentBlock ContentBlock `json:"content_block,omitempty"`
    Delta        Delta        `json:"delta,omitempty"`
}

type ContentBlock struct {
    Type string `json:"type"`
    Text string `json:"text,omitempty"`
    ID   string `json:"id,omitempty"`
    Name string `json:"name,omitempty"`
}

type Delta struct {
    Type        string `json:"type"`
    Text        string `json:"text,omitempty"`
    PartialJSON string `json:"partial_json,omitempty"`
}

// StreamEvent is the full stream_event with its nested event.
type StreamEvent struct {
    Type            string         `json:"type"`
    Event           StreamSubEvent `json:"event"`
    SessionID       string         `json:"session_id"`
    ParentToolUseID *string        `json:"parent_tool_use_id"`
}

// ResultEvent is the final event of a run.
type ResultEvent struct {
    Type         string  `json:"type"`
    Subtype      string  `json:"subtype"`
    IsError      bool    `json:"is_error"`
    Result       string  `json:"result"`
    SessionID    string  `json:"session_id"`
    TotalCostUSD float64 `json:"total_cost_usd"`
    DurationMs   int     `json:"duration_ms"`
    NumTurns     int     `json:"num_turns"`
}

// ParseEvents reads NDJSON lines from a reader and sends parsed events to a channel.
func ParseEvents(r io.Reader, events chan<- json.RawMessage, errs chan<- error) {
    scanner := bufio.NewScanner(r)
    // Increase buffer for large events
    scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)

    for scanner.Scan() {
        line := scanner.Bytes()
        if len(line) == 0 {
            continue
        }
        // Make a copy since scanner reuses the buffer
        cp := make([]byte, len(line))
        copy(cp, line)
        events <- json.RawMessage(cp)
    }

    if err := scanner.Err(); err != nil {
        errs <- err
    }
    close(events)
}

Spawning Claude

package claude

import (
    "encoding/json"
    "fmt"
    "io"
    "os"
    "os/exec"
    "strings"
)

type RunConfig struct {
    Prompt       string
    Cwd          string
    SessionID    string
    Model        string
    SettingsPath string
    AllowedTools []string
    MaxTurns     int
}

// UserMessage is the NDJSON input format for stdin.
type UserMessage struct {
    Type    string         `json:"type"`
    Message MessagePayload `json:"message"`
}

type MessagePayload struct {
    Role    string        `json:"role"`
    Content []ContentPart `json:"content"`
}

type ContentPart struct {
    Type string `json:"type"`
    Text string `json:"text"`
}

// SpawnResult holds the started process and its I/O handles.
type SpawnResult struct {
    Cmd    *exec.Cmd
    Stdin  io.WriteCloser
    Stdout io.ReadCloser
}

func SpawnClaude(cfg RunConfig) (*SpawnResult, error) {
    args := []string{
        "-p",
        "--input-format", "stream-json",
        "--output-format", "stream-json",
        "--verbose",
        "--include-partial-messages",
        "--permission-mode", "default",
    }

    if cfg.SessionID != "" {
        args = append(args, "--resume", cfg.SessionID)
    }
    if cfg.Model != "" {
        args = append(args, "--model", cfg.Model)
    }
    if cfg.SettingsPath != "" {
        args = append(args, "--settings", cfg.SettingsPath)
    }
    if len(cfg.AllowedTools) > 0 {
        args = append(args, "--allowedTools", strings.Join(cfg.AllowedTools, ","))
    }
    if cfg.MaxTurns > 0 {
        args = append(args, "--max-turns", fmt.Sprintf("%d", cfg.MaxTurns))
    }

    cmd := exec.Command("claude", args...)
    cmd.Dir = cfg.Cwd

    // Clean env
    env := os.Environ()
    filtered := make([]string, 0, len(env))
    for _, e := range env {
        if !strings.HasPrefix(e, "CLAUDECODE=") {
            filtered = append(filtered, e)
        }
    }
    cmd.Env = filtered
    cmd.Stderr = os.Stderr // or capture separately

    // Get pipes before Start - cannot mix StdinPipe with cmd.Stdin assignment
    stdin, err := cmd.StdinPipe()
    if err != nil {
        return nil, err
    }
    stdout, err := cmd.StdoutPipe()
    if err != nil {
        return nil, err
    }

    if err := cmd.Start(); err != nil {
        return nil, err
    }

    return &SpawnResult{Cmd: cmd, Stdin: stdin, Stdout: stdout}, nil
}

// WritePrompt sends a user message to Claude's stdin.
func WritePrompt(stdin io.WriteCloser, prompt string) error {
    msg := UserMessage{
        Type: "user",
        Message: MessagePayload{
            Role: "user",
            Content: []ContentPart{
                {Type: "text", Text: prompt},
            },
        },
    }

    data, err := json.Marshal(msg)
    if err != nil {
        return err
    }

    _, err = fmt.Fprintf(stdin, "%s\n", data)
    return err
}

Bubbletea Integration Skeleton

The 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.

package main

import (
    "bufio"
    "encoding/json"
    "fmt"
    "io"
    "os/exec"
    "strings"

    tea "github.com/charmbracelet/bubbletea"
)

type eventMsg struct{ raw json.RawMessage }
type doneMsg struct{ err error }

type model struct {
    output    strings.Builder
    scanner   *bufio.Scanner
    stdin     io.WriteCloser
    done      bool
    err       error
}

func (m model) Init() tea.Cmd {
    cmd := exec.Command("claude", "-p",
        "--input-format", "stream-json",
        "--output-format", "stream-json",
        "--verbose", "--include-partial-messages",
    )
    stdin, _ := cmd.StdinPipe()
    stdout, _ := cmd.StdoutPipe()
    if err := cmd.Start(); err != nil {
        return func() tea.Msg { return doneMsg{err: err} }
    }

    prompt, _ := json.Marshal(map[string]any{
        "type": "user",
        "message": map[string]any{
            "role":    "user",
            "content": []map[string]string{{"type": "text", "text": "Explain what a goroutine is"}},
        },
    })
    fmt.Fprintf(stdin, "%s\n", prompt)

    m.stdin = stdin
    scanner := bufio.NewScanner(stdout)
    scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
    m.scanner = scanner

    return readNext(scanner)
}

// readNext returns a tea.Cmd that blocks until the next NDJSON line arrives.
func readNext(s *bufio.Scanner) tea.Cmd {
    return func() tea.Msg {
        for s.Scan() {
            line := s.Bytes()
            if len(line) == 0 {
                continue
            }
            cp := make([]byte, len(line))
            copy(cp, line)
            return eventMsg{raw: cp}
        }
        return doneMsg{err: s.Err()}
    }
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case eventMsg:
        var base struct {
            Type  string `json:"type"`
            Event struct {
                Type  string `json:"type"`
                Delta struct {
                    Type string `json:"type"`
                    Text string `json:"text"`
                } `json:"delta"`
            } `json:"event"`
        }
        json.Unmarshal(msg.raw, &base)
        if base.Type == "stream_event" &&
            base.Event.Type == "content_block_delta" &&
            base.Event.Delta.Type == "text_delta" {
            m.output.WriteString(base.Event.Delta.Text)
        }
        if base.Type == "result" {
            m.done = true
            m.stdin.Close()
        }
        return m, readNext(m.scanner)

    case doneMsg:
        m.done = true
        m.err = msg.err
        return m, tea.Quit

    case tea.KeyMsg:
        if msg.String() == "q" || msg.String() == "ctrl+c" {
            return m, tea.Quit
        }
    }
    return m, nil
}

func (m model) View() string {
    status := "streaming..."
    if m.done {
        status = "done"
    }
    if m.err != nil {
        return fmt.Sprintf("error: %v\n", m.err)
    }
    return fmt.Sprintf("[%s]\n\n%s\n\nPress q to quit.", status, m.output.String())
}

func main() {
    p := tea.NewProgram(model{})
    if _, err := p.Run(); err != nil {
        fmt.Printf("error: %v\n", err)
    }
}

Patterns

Cancellation (TypeScript)

function cancelRun(child: ChildProcess): void {
  child.kill('SIGINT')

  // Fallback: SIGKILL after 5s if SIGINT didn't work
  setTimeout(() => {
    if (child.exitCode === null) {
      child.kill('SIGKILL')
    }
  }, 5000)
}

Follow-up Message (TypeScript)

function sendFollowUp(child: ChildProcess, text: string): void {
  const msg = JSON.stringify({
    type: 'user',
    message: {
      role: 'user',
      content: [{ type: 'text', text }],
    },
  })
  child.stdin!.write(msg + '\n')
}

Track Tool Calls (TypeScript)

interface ToolCall {
  id: string
  name: string
  inputFragments: string[]
  complete: boolean
}

const activeTools = new Map<number, ToolCall>() // index -> tool

function handleStreamEvent(event: any): void {
  const sub = event.event
  if (!sub) return

  switch (sub.type) {
    case 'content_block_start':
      if (sub.content_block.type === 'tool_use') {
        activeTools.set(sub.index, {
          id: sub.content_block.id,
          name: sub.content_block.name,
          inputFragments: [],
          complete: false,
        })
      }
      break

    case 'content_block_delta':
      if (sub.delta.type === 'input_json_delta') {
        const tool = activeTools.get(sub.index)
        if (tool) {
          tool.inputFragments.push(sub.delta.partial_json)
        }
      }
      break

    case 'content_block_stop': {
      const tool = activeTools.get(sub.index)
      if (tool) {
        tool.complete = true
        const fullInput = JSON.parse(tool.inputFragments.join(''))
        console.log(`Tool ${tool.name}: ${JSON.stringify(fullInput)}`)
      }
      break
    }
  }
}

Diagnostic Ring Buffer (TypeScript)

Keep a ring buffer of the last N stderr lines for error reporting.

const MAX_LINES = 100

class RingBuffer {
  private lines: string[] = []

  push(line: string): void {
    this.lines.push(line)
    if (this.lines.length > MAX_LINES) {
      this.lines.shift()
    }
  }

  tail(n: number): string[] {
    return this.lines.slice(-n)
  }
}

// Usage:
const stderrBuf = new RingBuffer()
child.stderr?.setEncoding('utf-8')
child.stderr?.on('data', (data: string) => {
  for (const line of data.split('\n').filter(l => l.trim())) {
    stderrBuf.push(line)
  }
})

// On error, include last 20 stderr lines in diagnostics
child.on('close', (code) => {
  if (code !== 0) {
    console.error('Last stderr:', stderrBuf.tail(20))
  }
})