Claude Code CLI Adapter
Overview
Mechanism: Spawns claude (Claude Code CLI) as a child process. Communicates via NDJSON (newline-delimited JSON) on stdout.
Why Claude for high-value tasks:
- Session resumption (
--resume <sessionId>) — stateful multi-turn without manual history management - Skills injection via
--add-dir— keeps prompts clean, no prompt stuffing - Highest quality reasoning for strategy and brand-sensitive copywriting
- Native tool use support
When to use:
- Activity Planner (decomposition and synthesis)
- Copywriter (brand tone, long-form content)
- SEO Specialist (nuanced keyword intent)
- Paid Ads Manager (ad copy quality matters)
- Data Analyst (complex data interpretation)
- Social Media Manager
Configuration
ClaudeAdapterConfig (stored in agent_configs.adapter_config):
interface ClaudeAdapterConfig {
model: string; // e.g. 'claude-sonnet-4-6'
cwd?: string; // working directory; auto-created if absent
promptTemplate?: string; // prepended to every task prompt (supports {{variables}})
env?: Record<string, string>; // per-agent env vars; values may be secret refs
timeoutSec: number; // hard timeout before SIGTERM (default: 300)
graceSec?: number; // seconds between SIGTERM and SIGKILL (default: 10)
maxTurnsPerRun?: number; // passed as --max-turns N; caps agentic turns per heartbeat
dangerouslySkipPermissions?: boolean; // dev only — bypasses Claude's permission prompts
}promptTemplate variable substitution — the template is rendered before dispatch using these variables:
| Variable | Value |
|---|---|
{{agentRole}} | e.g. copywriter |
{{agentName}} | e.g. "AI Copywriter" |
{{tenantId}} | Tenant identifier |
{{runId}} | This activity run’s ID |
{{wakeReason}} | e.g. new_task, review_feedback |
Example template stored on an agent config:
You are {{agentName}} for tenant {{tenantId}}.
Always output in British English.
Run ID: {{runId}} — include this in your result header.env secret references — values prefixed secret: are resolved from the tenant’s secrets store before process spawn:
{ "BRAND_API_KEY": "secret:brand_api_key", "TONE": "formal" }Full Subprocess Flow
This is how data actually travels in and out of the Claude Code CLI process:
Worker process (Node.js)
│
│ 1. ASSEMBLE INPUTS
│ buildActivityPrompt() → fullPrompt string
│ writeSkillsToTempDir() → /tmp/skills-{uuid}/
│
│ 2. SPAWN SUBPROCESS
│ ┌─────────────────────────────────────────────────────────┐
│ │ child_process.spawn('claude', [...args], { │
│ │ cwd: agentConfig.cwd, │
│ │ env: { ...process.env, ...resolvedSecrets }, │
│ │ stdio: ['pipe', 'pipe', 'pipe'] ← stdin+stdout piped │
│ │ }) │
│ └─────────────────────────────────────────────────────────┘
│
│ 3. DATA IN — CLI flags + stdin
│ --print - ← read prompt from stdin (avoids Windows quoting bugs)
│ --output-format stream-json ← tells Claude to emit NDJSON
│ --verbose
│ --model claude-sonnet-4-6
│ --add-dir /tmp/skills-{uuid}/ ← skills dir (Claude reads files on demand)
│ --resume sess_abc123 ← optional: resume prior session
│ --max-turns 10 ← cap agentic turns
│ --dangerously-skip-permissions ← dev only
│ [stdin] = rendered prompt string ← written to proc.stdin, then closed
│
│ 4. DATA OUT — read from child.stdout line-by-line (NDJSON)
│ Each line is one complete JSON event. Adapter parses each line as it arrives.
│
└──────────────────────────────────────────────────────────────────The NDJSON event stream
// Line 1 — always first. Adapter captures session_id here.
{ "type": "system", "subtype": "init", "session_id": "sess_abc123", "model": "claude-sonnet-4-6" }
// Lines 2..N — streaming text output, one chunk per line
{ "type": "assistant", "message": { "content": [{ "type": "text", "text": "## Why Your Google Ads" }] } }
{ "type": "assistant", "message": { "content": [{ "type": "text", "text": " Aren't Converting\n\n" }] } }
// Tool use — if the agent calls rag_search, wordpress_publish, etc.
{ "type": "tool_use", "id": "tu_001", "name": "rag_search", "input": { "query": "hot water systems", "dataset": "client_documents" } }
// Tool result — the control plane intercepts the tool call and returns a result
// (Claude Code's permission/tool layer handles this — adapter sees it in stream)
{ "type": "tool_result", "tool_use_id": "tu_001", "content": "[chunk 1]...[chunk 2]..." }
// Final event — process exits after this. Adapter reads usage here.
{ "type": "result", "subtype": "success", "result": "...", "usage": { "input_tokens": 14200, "output_tokens": 5900 }, "session_id": "sess_abc123" }How the adapter reads the stream
// packages/agent-engine/src/adapters/claude.ts (simplified)
async dispatch(request: DispatchRequest): Promise<DispatchResult> {
const args = buildArgs(request); // assemble CLI flags
const child = spawn('claude', args, {
cwd: this.config.cwd,
env: await resolveEnv(this.config),
stdio: ['pipe', 'pipe', 'pipe'], // stdin piped (for prompt), stdout+stderr piped
});
// Write the full prompt to stdin and close — avoids Windows cmd.exe quoting bugs
child.stdin.write(renderedPrompt, 'utf-8');
child.stdin.end();
let fullText = '';
let sessionId: string | null = null;
let inputTokens = 0;
let outputTokens = 0;
// Read stdout line by line — each line is one complete NDJSON event
const rl = createInterface({ input: child.stdout });
for await (const line of rl) {
const event = JSON.parse(line);
if (event.type === 'system' && event.subtype === 'init') {
sessionId = event.session_id; // ← capture session for resumption
}
if (event.type === 'assistant') {
for (const block of event.message.content) {
if (block.type === 'text') {
fullText += block.text; // ← accumulate streamed text
this.emit('text_delta', block.text); // ← live-stream to UI via SSE
}
}
}
if (event.type === 'result' && event.subtype === 'success') {
inputTokens = event.usage.input_tokens;
outputTokens = event.usage.output_tokens;
}
}
await onProcessExit(child); // wait for clean exit (or timeout)
return {
text: fullText,
sessionId,
usage: { inputTokens, outputTokens },
cost: calculateCost(request.model, inputTokens, outputTokens),
durationMs: Date.now() - startTime,
};
}Key I/O summary
| Concern | How it’s handled |
|---|---|
| Passing the prompt | --print - flag + stdin — prompt written to proc.stdin to avoid Windows cmd.exe quoting bugs |
| Passing skill files | --add-dir /tmp/skills/ — Claude reads these files during execution, not upfront |
| Passing session context | --resume sess_abc123 — Claude’s own session store, control plane only tracks the ID |
| Getting streamed text back | Read stdout line by line; accumulate text blocks from assistant events |
| Getting the session ID | Captured from first system/init event; saved to sessions table |
| Getting token usage | Read from final result event |
| Tool calls | Claude emits tool_use events; Claude Code’s built-in tool layer handles execution (file read, bash); custom tools like rag_search are registered as allowed tools |
| Process cleanup | Skills temp dir deleted in finally block after process exits |
| Timeout | SIGTERM at timeoutMs, SIGKILL at timeoutMs + graceMs |
Why --print - and stdin instead of -p "..."?
--print - tells Claude Code to run in non-interactive mode and read the prompt from stdin rather than a CLI arg. The prompt is written to proc.stdin then closed. This avoids Windows cmd.exe shell-quoting bugs that occur with long strings containing double-quotes, curly braces, and other special characters when passed as a CLI argument.
Why --output-format stream-json and not plain text?
Plain text (--output-format text) gives you only the final output, with no visibility into tool calls, no session ID in the stream, and no structured usage data. stream-json gives you the full event log as it happens, which enables live UI streaming, tool call observation, and reliable session ID capture.
Invocation pattern
echo "<rendered prompt>" | claude --print - \
--output-format stream-json --verbose \
--model claude-sonnet-4-6 \
--add-dir /tmp/skills-{uuid} \
[--resume <sessionId>] \
[--max-turns 10] \
[--dangerously-skip-permissions]Session Handling
- Session ID extracted from
system/initevent on the first call - Persisted to
sessionstable in PostgreSQL - Subsequent tasks on the same campaign use
--resume <sessionId> - TTL: 30 days
- If
cwdchanges between runs, the session is reset (fresh context)
Health Checks
Before dispatching a task, the worker optionally runs testAdapter() to verify the adapter is operational.
ClaudeAdapter checks:
| Check | What it verifies |
|---|---|
| CLI installed | claude --version exits 0 |
| API key present | ANTHROPIC_API_KEY is set in env |
| Auth valid | claude auth status returns authenticated |
| cwd accessible | Configured working directory exists and is readable |
| Probe response | Sends a single-token prompt; expects a result within 10 s |
When health checks run:
- On agent creation — validate before the agent is set to
active - On manual “Test Connection” from the Manage App agent config screen
- Optionally on worker startup — configurable per deployment; skipped if
ADAPTER_HEALTH_CHECK_ON_START=false - Health check failures do not block existing queued work — they surface as a warning on the agent config record
Timeout Handling
The Claude adapter uses a two-stage graceful shutdown:
- At
timeoutMs: send SIGTERM to the child process — gives Claude a chance to flush its current output and exit cleanly - If the process is still alive after
graceMs(default 10 s): send SIGKILL — hard kill
In all cases the activity run is marked failed with error: 'timeout' and BullMQ retry policy picks it up.
async function withGracefulTimeout(
child: ChildProcess,
timeoutMs: number,
graceMs: number
): Promise<void> {
const sigterm = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
const sigkill = setTimeout(() => child.kill('SIGKILL'), timeoutMs + graceMs);
try {
await onProcessExit(child);
} finally {
clearTimeout(sigterm);
clearTimeout(sigkill);
}
}Cost Source
Token counts come from the NDJSON result event → MODEL_PRICING[model] × tokens.