Published on

Wrapping Claude CLI for Agentic Applications

Authors

The official Agent SDK exists. It's nice. But it requires OAuth flows that don't work for headless servers, and the API surface is narrower than what the CLI exposes. Sometimes you just want to spawn a subprocess and parse the output.

This is a deep-dive on wrapping Claude CLI for programmatic use. We're covering input methods, output formats, structured schemas, and permission patterns. By the end you'll have production-ready wrapper code.


Why Wrap the CLI?

Three reasons I keep coming back to CLI wrappers over the SDK:

OAuth is annoying in CI. The SDK wants browser-based authentication. Fine for local dev, but try running that in GitHub Actions. You can work around it with API keys, but then you're paying for tokens instead of using your Pro subscription.

The CLI exposes more. Session management, tool restrictions, custom agents, MCP servers. Some features hit the CLI before they hit the SDK.

Subprocesses are debuggable. When something breaks, I can run the exact same command manually. Harder to do with SDK calls.

The tradeoff? You're parsing text and JSON. You're managing process lifecycles. It's more work, but the control is worth it.


Input/Output Format Matrix

Claude CLI supports several input and output combinations:

Input FormatOutput FormatFlag ComboUse Case
ArgumentText-p "prompt"Simple queries
StdinTextcat file | claude -pFile analysis
ArgumentJSON-p --output-format jsonStructured results
StdinJSONpipe | -p --output-format jsonData pipelines
ArgumentStream-JSON-p --output-format stream-json --verboseReal-time progress
Stream-JSONStream-JSON--input-format stream-json --output-format stream-jsonAgent chaining

The last row is the interesting one. Stream-JSON input lets you pass an existing conversation to a new Claude instance. Useful for multi-phase pipelines.


Getting Data In

Five ways to feed prompts to Claude.

1. Direct Argument

The simplest:

claude -p "Explain the authentication flow"

2. Stdin Pipe

Pass file contents or command output:

cat logs.txt | claude -p "Explain these errors"
git diff | claude -p "Review these changes"
docker logs app | claude -p "What's causing the crash?"

3. File Redirect

Slightly different from piping:

claude -p "Analyze this code" < src/main.ts

4. Here-Documents

Multi-line prompts in scripts:

claude -p "$(cat <<EOF
Review this code for:
1. Security issues
2. Performance problems
3. Best practices
EOF
)"

5. Stream-JSON Input

For multi-turn conversations:

claude -p --output-format stream-json "First task" | \
  claude -p --input-format stream-json --output-format stream-json "Process results" | \
  claude -p --input-format stream-json "Final report"

The format is NDJSON (newline-delimited JSON):

{"type":"init","session_id":"abc123","timestamp":"2026-01-01T00:00:00Z"}
{"type":"message","role":"user","content":[{"type":"text","text":"Hello"}]}
{"type":"message","role":"assistant","content":[{"type":"text","text":"Hi!"}]}

Each line is a complete JSON object. First agent outputs stream-JSON, second agent reads it and continues the conversation.


Getting Data Out

Three output modes, each with different parsing requirements.

1. Text (Default)

Plain text, human-readable:

claude -p "Summarize this project"
# Just text output

Good for quick scripts where you dump output to a file:

claude -p "Generate a README" > README.md

Bad for anything you need to parse programmatically.

2. JSON (--output-format json)

Structured output with metadata:

claude -p "Summarize this project" --output-format json

Returns:

{
  "type": "result",
  "subtype": "success",
  "total_cost_usd": 0.0034,
  "is_error": false,
  "duration_ms": 2847,
  "duration_api_ms": 1923,
  "num_turns": 4,
  "result": "Response text here...",
  "session_id": "abc-123-def"
}

Parse with jq:

result=$(claude -p "Task" --output-format json)
response=$(echo "$result" | jq -r '.result')
cost=$(echo "$result" | jq -r '.total_cost_usd')
error=$(echo "$result" | jq -r '.is_error')

3. Stream-JSON (--output-format stream-json)

Real-time NDJSON. Requires --verbose for full output:

claude -p "Build the application" --output-format stream-json --verbose

Event types you'll see:

TypeWhat It Contains
initSession ID, timestamp
messageAssistant/user messages
tool_useTool name and parameters
tool_resultTool execution output
resultFinal status
stream_eventPartial tokens (with --include-partial-messages)

For token-by-token streaming:

claude -p "Write a poem" \
  --output-format stream-json \
  --verbose \
  --include-partial-messages | \
  jq -rj 'select(.type == "stream_event" and .event.delta.type? == "text_delta") | .event.delta.text'

Structured Output with JSON Schema

This is the killer feature for agentic use. You can force Claude's output to match a schema:

claude -p "Extract function names from auth.py" \
  --output-format json \
  --json-schema '{
    "type": "object",
    "properties": {
      "functions": {
        "type": "array",
        "items": {"type": "string"}
      }
    },
    "required": ["functions"]
  }'

Response includes a structured_output field with validated data:

{
  "type": "result",
  "subtype": "success",
  "result": "I found three functions in auth.py...",
  "structured_output": {
    "functions": ["login", "logout", "authenticate"]
  },
  "session_id": "..."
}

Two things to know:

  1. Schema validation happens after Claude finishes. It's not constrained generation during inference.
  2. You must use --output-format json. Text mode doesn't include structured_output.

Complex Schema Example

claude -p "Analyze the codebase" \
  --output-format json \
  --json-schema '{
    "type": "object",
    "properties": {
      "summary": {"type": "string"},
      "files": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "path": {"type": "string"},
            "purpose": {"type": "string"},
            "complexity": {"type": "integer", "minimum": 1, "maximum": 10}
          },
          "required": ["path", "purpose"]
        }
      },
      "recommendations": {
        "type": "array",
        "items": {"type": "string"}
      }
    },
    "required": ["summary", "files"]
  }'

Parse in TypeScript:

interface CodebaseAnalysis {
  summary: string;
  files: Array<{
    path: string;
    purpose: string;
    complexity?: number;
  }>;
  recommendations?: string[];
}

const result = JSON.parse(stdout);
const analysis: CodebaseAnalysis = result.structured_output;

Tool Configuration

Control which tools Claude can use.

--tools Restricts Available Tools

# Only these tools
claude -p "Fix the bug" --tools "Bash,Edit,Read"

# No tools at all (pure Q&A)
claude -p "Explain this code" --tools ""

# Everything (default)
claude -p "Implement feature" --tools "default"

--allowedTools Auto-Approves Specific Tools

These run without permission prompts:

claude -p "Create a commit" --allowedTools "Bash(git *),Read,Edit"
claude -p "Run tests" --allowedTools "Bash(npm run *)"

Pattern syntax:

PatternWhat It Matches
Bash(git *)Any command starting with git
Edit(src/**)Edit files under src/
Read(.env*)Read .env, .env.local, etc.
mcp__github__*All GitHub MCP tools

Watch out: Bash(git diff*) matches git diff-index too. Add a space: Bash(git diff *).

--disallowedTools Blocks Tools Entirely

Removes them from context:

claude -p "Explain code" --disallowedTools "Edit,Write"
claude -p "Run tests" --disallowedTools "Bash(rm *),Bash(sudo *)"

Permission Modes for CI/CD

Four modes available via --permission-mode:

ModeBehaviorUse Case
defaultAsks on first useNormal development
acceptEditsAuto-approves file edits, asks for bashTrusted projects
planRead-only, no edits or commandsAnalysis only
bypassPermissionsSkips everythingSandboxed CI/CD

The Nuclear Option

--dangerously-skip-permissions skips all prompts:

claude -p "Fix all bugs and commit" --dangerously-skip-permissions

Use this only in containers or sandboxes. Combine with limits:

docker run --rm -v "$PWD:/workspace" my-claude-image \
  claude -p "Implement the feature described in TASK.md" \
    --dangerously-skip-permissions \
    --max-turns 20 \
    --max-budget-usd 10.00 \
    --output-format json

Session Management

Sessions persist conversation history.

Continue Last Session

claude -p "Start work"
claude -p "Continue from where we left off" --continue

Resume Specific Session

# Capture session ID
session_id=$(claude -p "Start review" --output-format json | jq -r '.session_id')

# Resume later
claude -p "Continue" --resume "$session_id"

Ephemeral Sessions

Don't save to disk:

claude -p "One-off query" --no-session-persistence

Good for CI/CD where you don't need history.


Production Wrapper Examples

Bash Wrapper

#!/bin/bash
set -e

claude_query() {
  local prompt="$1"
  local schema="$2"
  local max_turns="${3:-10}"
  local budget="${4:-5.00}"
  
  local cmd="claude -p \"$prompt\" \
    --output-format json \
    --max-turns $max_turns \
    --max-budget-usd $budget"
  
  if [ -n "$schema" ]; then
    cmd="$cmd --json-schema '$schema'"
  fi
  
  local result
  result=$(eval "$cmd" 2>/dev/null)
  
  local is_error
  is_error=$(echo "$result" | jq -r '.is_error')
  
  if [ "$is_error" = "true" ]; then
    echo "Error: $(echo "$result" | jq -r '.result')" >&2
    return 1
  fi
  
  echo "$result"
}

# Usage
result=$(claude_query "Analyze the auth module" '{
  "type": "object",
  "properties": {
    "issues": {"type": "array", "items": {"type": "string"}},
    "severity": {"type": "string"}
  },
  "required": ["issues", "severity"]
}')

issues=$(echo "$result" | jq -r '.structured_output.issues[]')

TypeScript Wrapper

import { spawn } from 'child_process';

interface ClaudeResult<T = unknown> {
  type: string;
  subtype: string;
  result: string;
  structured_output?: T;
  session_id: string;
  total_cost_usd: number;
  is_error: boolean;
  duration_ms: number;
}

interface ClaudeOptions {
  schema?: object;
  maxTurns?: number;
  maxBudget?: number;
  allowedTools?: string[];
  sessionId?: string;
  cwd?: string;
}

async function claudeQuery<T>(
  prompt: string,
  options: ClaudeOptions = {}
): Promise<ClaudeResult<T>> {
  const args = ['-p', prompt, '--output-format', 'json'];

  if (options.schema) {
    args.push('--json-schema', JSON.stringify(options.schema));
  }
  if (options.maxTurns) {
    args.push('--max-turns', String(options.maxTurns));
  }
  if (options.maxBudget) {
    args.push('--max-budget-usd', String(options.maxBudget));
  }
  if (options.allowedTools?.length) {
    args.push('--allowedTools', options.allowedTools.join(','));
  }
  if (options.sessionId) {
    args.push('--resume', options.sessionId);
  }

  return new Promise((resolve, reject) => {
    const proc = spawn('claude', args, {
      cwd: options.cwd,
      stdio: ['ignore', 'pipe', 'pipe'],
    });

    let stdout = '';
    let stderr = '';

    proc.stdout.on('data', (data) => (stdout += data));
    proc.stderr.on('data', (data) => (stderr += data));

    proc.on('close', (code) => {
      if (code !== 0) {
        reject(new Error(`Claude exited with ${code}: ${stderr}`));
        return;
      }

      try {
        const result = JSON.parse(stdout) as ClaudeResult<T>;
        if (result.is_error) {
          reject(new Error(result.result));
          return;
        }
        resolve(result);
      } catch (e) {
        reject(new Error(`Failed to parse: ${stdout}`));
      }
    });
  });
}

// Usage
interface ReviewResult {
  issues: string[];
  severity: 'low' | 'medium' | 'high';
  approved: boolean;
}

const result = await claudeQuery<ReviewResult>(
  'Review the PR for security issues',
  {
    schema: {
      type: 'object',
      properties: {
        issues: { type: 'array', items: { type: 'string' } },
        severity: { type: 'string', enum: ['low', 'medium', 'high'] },
        approved: { type: 'boolean' },
      },
      required: ['issues', 'severity', 'approved'],
    },
    maxTurns: 10,
    maxBudget: 5.0,
  }
);

console.log(result.structured_output?.issues);

Gotchas

Things that bit me:

Session IDs must be UUIDs. Not arbitrary strings. --session-id "my-session" fails. Use a real UUID.

Stream-JSON needs --verbose. Without it you get minimal output. Add --include-partial-messages for token streaming.

JSON schema needs JSON output. --json-schema without --output-format json gives you text without the structured_output field.

Tool patterns are prefix matches. Bash(git diff*) matches git diff-index. Use Bash(git diff *) with a trailing space.

--dangerously-skip-permissions is dangerous. Don't run it on your host machine. Use Docker, --sandbox, or a VM.

Exit codes are simple. 0 = success, 1 = error. Check is_error in JSON output for details.

Model names changed. It's claude-sonnet-4-6 now, not claude-3.5-sonnet. Aliases like opus, sonnet, haiku still work.


Quick Reference

# Input methods
claude -p "prompt"                             # argument
cat file | claude -p "analyze"                 # stdin
claude -p "query" < file.txt                   # redirect
claude -p --input-format stream-json           # stream input

# Output formats  
claude -p "query"                              # text (default)
claude -p "query" --output-format json         # JSON
claude -p "query" --output-format stream-json --verbose  # streaming

# Structured output
claude -p "query" --output-format json --json-schema '{...}'

# Tool control
claude -p "query" --tools "Read,Edit"          # restrict
claude -p "query" --allowedTools "Bash(git *)" # auto-approve
claude -p "query" --disallowedTools "Edit"     # block

# Permissions
claude -p "query" --permission-mode plan       # read-only
claude -p "query" --permission-mode acceptEdits
claude -p "query" --dangerously-skip-permissions

# Sessions
claude -p "query" --continue                   # continue last
claude -p "query" --resume "uuid"              # specific session
claude -p "query" --no-session-persistence     # ephemeral

# Limits
claude -p "query" --max-turns 10
claude -p "query" --max-budget-usd 5.00

# Model selection
claude -p "query" --model opus
claude -p "query" --model haiku --fallback-model sonnet

Wrapping Up

The CLI gives you everything you need to build agents. Structured schemas mean you can parse responses reliably. Permission modes let you run unattended. Session management handles conversation state.

Start with the bash wrapper for quick scripts. Graduate to TypeScript when you need type safety and better error handling. And always sandbox your --dangerously-skip-permissions runs.