Skip to Content
AdaptersClaude Code CLI Adapter

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:

VariableValue
{{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

ConcernHow 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 backRead stdout line by line; accumulate text blocks from assistant events
Getting the session IDCaptured from first system/init event; saved to sessions table
Getting token usageRead from final result event
Tool callsClaude 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 cleanupSkills temp dir deleted in finally block after process exits
TimeoutSIGTERM 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/init event on the first call
  • Persisted to sessions table in PostgreSQL
  • Subsequent tasks on the same campaign use --resume <sessionId>
  • TTL: 30 days
  • If cwd changes 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:

CheckWhat it verifies
CLI installedclaude --version exits 0
API key presentANTHROPIC_API_KEY is set in env
Auth validclaude auth status returns authenticated
cwd accessibleConfigured working directory exists and is readable
Probe responseSends 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:

  1. At timeoutMs: send SIGTERM to the child process — gives Claude a chance to flush its current output and exit cleanly
  2. 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.

© 2026 Leadmetrics — Internal use only