Anthropic Claude
Category: AI / LLM
Adapter: ClaudeAdapter in packages/agent-core/src/adapters/claude.ts
External: Claude Code CLI subprocess (claude binary)
Purpose
Claude is the primary AI backbone of Leadmetrics. All content agents (Blog Writer, Content Brief Writer, Keyword Researcher, etc.) run on Claude by default. Claude is invoked as a subprocess via the Claude Code CLI rather than the REST API directly — this gives access to Claude’s tool use, session resumption, and the --add-dir skill injection mechanism.
Why CLI subprocess, not REST API
| Feature | CLI subprocess | REST API |
|---|---|---|
| Tool use (web_search, web_fetch, rag_search) | Native — Claude handles tool routing | Manual tool loop required |
| Session resumption | --resume <session-id> flag | Manual history management |
Skill injection (--add-dir) | Built-in | Not available |
| NDJSON streaming | Native | SSE streaming (different format) |
| Token counting | Included in stream | Separate API call or estimate |
Config Structure
Platform config (env vars)
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CLAUDE_MODEL=claude-sonnet-4-6 # Default model for most agents
CLAUDE_EXPENSIVE_MODEL=claude-opus-4-6 # For Strategy Writer, Report Writer
CLAUDE_CHEAP_MODEL=claude-haiku-4-5-20251001 # For Topic Researcher, Research Note Writer
CLAUDE_CLI_PATH=/usr/local/bin/claude # Path to the claude binaryPer-agent config (stored in agent_configs table)
interface ClaudeAgentConfig {
model?: string; // Override per agent, e.g. "claude-opus-4-6"
maxTokens?: number; // Default: 8192
temperature?: number; // Not supported in Claude Code CLI — omit
systemPrompt: string; // Full system prompt for the agent
tools?: string[]; // Allowed tools: ["web_search", "web_fetch", "rag_search"]
}Integration Pattern
CLI invocation
The agent execution engine spawns a child process for each activity:
const args = [
'--output-format', 'stream-json',
'--model', model,
'--max-tokens', String(maxTokens),
'--system', systemPrompt,
'--resume', sessionId ?? 'new',
'--add-dir', skillsDir, // Path to mounted skill files
'--print', // Non-interactive mode
prompt, // The assembled user prompt
];
const proc = spawn('claude', args, {
env: { ...process.env, ANTHROPIC_API_KEY: config.ANTHROPIC_API_KEY },
stdio: ['ignore', 'pipe', 'pipe'],
});NDJSON stream parsing
Each line from stdout is a JSON object. The adapter processes them as they arrive:
for await (const line of readLines(proc.stdout)) {
const event = JSON.parse(line);
switch (event.type) {
case 'assistant':
// Content block — text or tool_use
yield { type: 'content', text: event.message.content };
break;
case 'tool_result':
// Tool call result (web_search, rag_search, etc.)
yield { type: 'tool_result', name: event.tool_name, result: event.result };
break;
case 'result':
// Final result — total tokens used
totalInputTokens = event.usage.input_tokens;
totalOutputTokens = event.usage.output_tokens;
break;
case 'system':
// Session ID — save for resumption
if (event.session_id) sessionId = event.session_id;
break;
}
}Session resumption
Claude Code CLI maintains conversation history via session IDs. On the first run, --resume new (or omit --resume) creates a new session. The session ID is extracted from the system event and stored on the activities row. Subsequent activities in the same pipeline (e.g. ACT-2604-001 → ACT-2604-002) resume the same session to preserve context.
// After first activity completes
await db.update(activities)
.set({ claudeSessionId: sessionId })
.where(eq(activities.id, activityId));
// Next activity in pipeline
const previousSessionId = await db.select({ claudeSessionId: activities.claudeSessionId })
.from(activities)
.where(eq(activities.id, previousActivityId));
const args = ['--resume', previousSessionId ?? 'new', ...];Skill injection (--add-dir)
The --add-dir flag mounts a directory of Markdown files as “skills” — Claude reads them as additional context at the start of each session. Skills are assembled per-tenant from MongoDB and written to a temp directory before the subprocess starts:
const skillsDir = await prepareSkillsDir(tenantId, agentType);
// Writes: /tmp/skills-{activityId}/client-context.md
// /tmp/skills-{activityId}/activity-planning-sop.md
// /tmp/skills-{activityId}/deliverable-types.mdCost Calculation
Token costs are calculated from the result event at the end of each stream:
const COST_PER_INPUT_MTok = modelCosts[model].inputPerMillionTokens; // USD
const COST_PER_OUTPUT_MTok = modelCosts[model].outputPerMillionTokens; // USD
const usdCost = (inputTokens / 1_000_000 * COST_PER_INPUT_MTok)
+ (outputTokens / 1_000_000 * COST_PER_OUTPUT_MTok);
// Stored in llm_calls.cost_usd and used for credit consumption
const creditsConsumed = Math.ceil(usdCost / CREDIT_EXCHANGE_RATE);Model cost reference is stored in packages/agent-core/src/costs.ts and updated as Anthropic publishes new pricing.
Test Cases
Unit tests (packages/agent-core/src/adapters/claude.test.ts)
| Test | Approach |
|---|---|
| Spawns subprocess with correct args | Mock spawn; assert args array |
Parses assistant event and yields content | Feed mock NDJSON stream; assert yielded content blocks |
Extracts session_id from system event | Assert sessionId set after stream |
Calculates cost from result event tokens | Assert usdCost matches expected formula |
| Handles subprocess exit code != 0 | Mock proc exits with code 1; assert error thrown |
| Handles malformed NDJSON line | Feed invalid JSON line; assert error logged, stream continues |
Uses --resume flag when sessionId provided | Assert --resume ${sessionId} in args |
Integration tests
| Test | Approach |
|---|---|
Full subprocess with real claude binary | Requires ANTHROPIC_API_KEY in CI; send simple prompt; assert content returned |
| Session resumption works across two calls | Run twice with same --resume ID; assert second response references first |
--add-dir skill files are read | Write skill file to temp dir; assert agent references skill content |
Cost of CI tests
Integration tests against the real Claude API cost money. Tag them @slow and run only on release branches or nightly, not on every PR.
Model Selection per Agent
| Agent | Model | Rationale |
|---|---|---|
| Strategy Writer | claude-opus-4-6 | Highest reasoning — foundational document |
| Report Writer | claude-opus-4-6 | Deep analysis across all channel data |
| Blog Writer | claude-sonnet-4-6 | Quality + speed balance |
| Content Brief Writer | claude-sonnet-4-6 | Research-heavy — Sonnet handles tool use well |
| Keyword Researcher | claude-sonnet-4-6 | — |
| Social Post Writer | claude-haiku-4-5-20251001 | Short output — speed and cost matter |
| GBP Post Writer | claude-haiku-4-5-20251001 | — |
| Topic Researcher | claude-haiku-4-5-20251001 | Runs locally alongside Ollama |
| Research Note Writer | claude-haiku-4-5-20251001 | — |
Related
- Adapter — Claude — full adapter implementation detail
- Agent Execution Engine — how adapters are called
- OpenAI Provider — alternative LLM adapter
- Ollama Provider — local LLM adapter
- Session / Memory Management — session resumption detail