| name | claude-code-automation |
| description | Run Claude Code programmatically in headless mode or via bidirectional stream-json protocol. Use when building agents, automating Claude, running headless Claude, implementing multi-turn conversations, or integrating Claude CLI into applications. |
Claude Code Automation
This skill covers running Claude Code programmatically - either one-shot with --print or multi-turn with the bidirectional stream-json protocol.
Quick Reference
| Mode | Use Case | Key Flags |
|---|---|---|
Headless (--print) |
One-shot tasks, background agents | --print --verbose --output-format stream-json |
| Bidirectional | Multi-turn conversations, persistent sessions | --input-format stream-json --output-format stream-json --verbose |
Headless Mode (--print)
For one-shot execution without interactive prompts.
Essential Flags
claude --print \
--verbose \ # REQUIRED with stream-json
--output-format stream-json \
--model sonnet \
--permission-mode bypassPermissions \ # Enable file writes
--append-system-prompt "context here" # Inject dynamic context
Critical Gotchas
--verboseis required withstream-jsonoutput - without it, Claude exits silently with status 1File writes silently fail without
--permission-mode bypassPermissionsSession hooks don't run in
--printmode - no SessionStart/SessionEnd--append-system-promptis ephemeral - NOT re-applied on--resume
Rust Implementation
pub fn run_headless_claude(prompt: &str, working_dir: &Path, system_prompt: &str) -> Result<()> {
let mut cmd = Command::new("claude")
.arg("--print")
.arg("--verbose")
.arg("--model").arg("sonnet")
.arg("--output-format").arg("stream-json")
.arg("--permission-mode").arg("bypassPermissions")
.arg("--append-system-prompt").arg(system_prompt)
.current_dir(working_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
// Write prompt to stdin, process output...
Ok(())
}
Bidirectional Protocol
For multi-turn conversations with a persistent Claude process.
Starting a Session
claude --input-format stream-json --output-format stream-json --verbose
Message Types
1. Initialize (set system prompt once)
{
"subtype": "initialize",
"systemPrompt": "Custom system prompt",
"appendSystemPrompt": "Additional context"
}
2. Send user messages
{
"type": "user",
"session_id": "",
"message": {
"role": "user",
"content": [{"type": "text", "text": "Your question here"}]
},
"parent_tool_use_id": null
}
Critical Gotcha: control_response
control_response is a top-level type, NOT a subtype of system:
// WRONG - never matches
if event.get("type") == Some("system")
&& event.get("subtype") == Some("control_response") { ... }
// CORRECT
if event.get("type") == Some("control_response") {
info!("Session initialized");
}
Rust Session Pattern
pub struct ClaudeSession {
process: Child,
stdin: ChildStdin,
}
impl ClaudeSession {
pub fn new(system_prompt: &str) -> Result<Self> {
let mut process = Command::new("claude")
.arg("--input-format").arg("stream-json")
.arg("--output-format").arg("stream-json")
.arg("--verbose")
.arg("--model").arg("sonnet")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdin = process.stdin.take().unwrap();
// Send initialize message
let init = serde_json::json!({
"subtype": "initialize",
"appendSystemPrompt": system_prompt
});
writeln!(stdin, "{}", init)?;
stdin.flush()?;
Ok(Self { process, stdin })
}
pub fn send_message(&mut self, text: &str) -> Result<()> {
let msg = serde_json::json!({
"type": "user",
"session_id": "",
"message": {
"role": "user",
"content": [{"type": "text", "text": text}]
},
"parent_tool_use_id": null
});
writeln!(self.stdin, "{}", msg)?;
self.stdin.flush()?;
Ok(())
}
}
Reading JSONL Transcripts
Claude session transcripts can be large. Direct file reads often fail.
Size limits:
- 256KB maximum for direct reads
- Individual JSONL lines can be 50k+ characters
Working solutions:
# Extract specific fields with jq
jq -r 'select(.type == "user") | .message.content' file.jsonl | head -20
# Python for complex extraction
python3 -c "
import json
for line in open('file.jsonl'):
obj = json.loads(line)
if obj.get('type') == 'user':
print(obj.get('message', {}).get('content', '')[:200])
"
When to Use Which
Use headless (--print) for:
- One-off background agents
- Each invocation needs different context
- Truly independent sessions
- Simple automation scripts
Use bidirectional for:
- Multi-turn conversations
- When system prompt must persist across messages
- Building interactive applications
- When
--resumebehavior is insufficient
Use interactive (normal) for:
- Human conversations
- Sessions requiring workspace trust dialogs
- When hooks are essential