- Published on
Wrapping Claude CLI for Agentic Applications
- Authors

- Name
- Avasdream
- @avasdream_
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 Format | Output Format | Flag Combo | Use Case |
|---|---|---|---|
| Argument | Text | -p "prompt" | Simple queries |
| Stdin | Text | cat file | claude -p | File analysis |
| Argument | JSON | -p --output-format json | Structured results |
| Stdin | JSON | pipe | -p --output-format json | Data pipelines |
| Argument | Stream-JSON | -p --output-format stream-json --verbose | Real-time progress |
| Stream-JSON | Stream-JSON | --input-format stream-json --output-format stream-json | Agent 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:
| Type | What It Contains |
|---|---|
init | Session ID, timestamp |
message | Assistant/user messages |
tool_use | Tool name and parameters |
tool_result | Tool execution output |
result | Final status |
stream_event | Partial 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:
- Schema validation happens after Claude finishes. It's not constrained generation during inference.
- You must use
--output-format json. Text mode doesn't includestructured_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:
| Pattern | What 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:
| Mode | Behavior | Use Case |
|---|---|---|
default | Asks on first use | Normal development |
acceptEdits | Auto-approves file edits, asks for bash | Trusted projects |
plan | Read-only, no edits or commands | Analysis only |
bypassPermissions | Skips everything | Sandboxed 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.