Spawning Claude Code CLI as a child process in Node.js, treating it as an interchangeable provider alongside OpenAI and Anthropic APIs behind a single complete() interface.
When building an AI-powered code review tool, you typically integrate with LLM APIs: OpenAI, Anthropic, OpenRouter. But Claude Code presents an interesting option — a fully agentic coding assistant that can read files, search codebases, and reason about code, all packaged as a CLI tool. The question is: how do you treat a CLI subprocess as just another provider in a unified API abstraction?
This post documents the pattern I arrived at for spawning Claude Code as a child process in a Node.js application, making it interchangeable with HTTP-based API providers behind a single complete() interface.
The core idea is a single function signature that all providers implement:
interface CompletionRequest {
config: ProviderConfig;
system: string;
messages: Message[];
tools?: ToolDefinition[];
maxTokens: number;
}
interface CompletionResponse {
text: string;
toolCalls: ToolCall[];
usage: { input: number; output: number };
}
async function complete(req: CompletionRequest): Promise<CompletionResponse>
For OpenAI and Anthropic, complete() makes an HTTP request to their respective APIs. For Claude Code, it spawns a subprocess. The caller doesn't know or care which path is taken.
The first challenge is locating the claude binary. This is straightforward in a terminal — it's on PATH. But GUI applications on macOS (Electron, Tauri, etc.) don't inherit the user's shell PATH. They launch from the Finder/Dock with a minimal environment, so which claude finds nothing.
The solution is to check well-known install locations in priority order:
function findClaude(): string {
const home = os.homedir();
const candidates = [
path.join(home, ".local/bin/claude"),
path.join(home, ".claude/local/claude"),
path.join(home, ".npm-global/bin/claude"),
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude",
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
// Fallback: assume it's on PATH (works in terminal contexts)
return "claude";
}
This same pattern applies to finding node itself if your GUI app needs to spawn Node processes — check Volta, nvm, fnm, and Homebrew install paths before falling back to PATH resolution.
Claude Code supports a machine-readable streaming mode via two flags:
--output-format stream-json
--input-format stream-json
In this mode, you write JSON messages to stdin and read line-delimited JSON events from stdout. There are three event types that matter:
| Event type | Purpose |
|---|---|
assistant | Text content from the model, delivered incrementally |
control_request | Permission prompts (file access, tool use) |
result | Final aggregated response text |
Here's the core of the subprocess interaction:
const claudeBin = findClaude();
const args = [
"--output-format", "stream-json",
"--input-format", "stream-json",
"--permission-prompt-tool", "stdio",
"--permission-mode", "dontAsk",
"--allowedTools", "Read,Grep,Glob",
"--verbose",
"--append-system-prompt", systemPrompt,
];
const proc = spawn(claudeBin, args, {
env: filteredEnv,
stdio: ["pipe", "pipe", "pipe"],
});
// Send the user prompt
const userMessage = JSON.stringify({
type: "user",
message: { role: "user", content: userPrompt },
});
proc.stdin.write(userMessage + "\n");
proc.stdin.end();
Then read events from stdout:
let resultText = "";
let lastAssistantText = "";
for await (const line of readLines(proc.stdout)) {
const event = JSON.parse(line);
switch (event.type) {
case "assistant": {
// Collect text blocks
for (const block of event.message?.content ?? []) {
if (block.type === "text") {
lastAssistantText += block.text;
}
}
break;
}
case "control_request": {
// Auto-approve permission requests
const response = JSON.stringify({
type: "control_response",
response: {
subtype: "success",
request_id: event.request_id,
response: { behavior: "allow", updatedInput: {} },
},
});
proc.stdin.write(response + "\n");
break;
}
case "result": {
resultText = event.result;
break;
}
}
}
After writing the user prompt, call proc.stdin.end() to signal that input is complete. However, if you need to handle control_request events (permission prompts), keep stdin open and only close it after receiving the result event. The implementation above simplifies this — in practice, you may need to defer stdin.end().
This is the most important architectural insight. When using API providers (OpenAI, Anthropic), your application manages the tool-calling loop explicitly: the model requests a tool call, you execute it locally, send the result back, and repeat until the model produces a final text response.
Claude Code handles all of this internally. When you pass --allowedTools Read,Grep,Glob, Claude Code reads files, searches the codebase, and reasons about the results on its own. From your application's perspective, you send a prompt and get back text.
This means your analysis pipeline needs to branch:
async function analyzeCode(
config: ProviderConfig,
systemPrompt: string,
messages: Message[],
): Promise<string> {
if (config.provider === "claudecode") {
// Single call. Claude Code handles tools internally.
const response = await complete({
config,
system: systemPrompt,
messages,
maxTokens: 16384,
});
return response.text;
}
// API providers: explicit multi-turn tool loop
const history = [...messages];
for (let turn = 0; turn < 10; turn++) {
const response = await complete({
config,
system: systemPrompt,
messages: history,
tools: TOOL_DEFINITIONS,
maxTokens: 16384,
});
if (response.toolCalls.length === 0) {
return response.text;
}
// Execute tool calls and add results to history
for (const call of response.toolCalls) {
const result = await executeTool(call);
history.push({ role: "tool", toolCallId: call.id, content: result });
}
}
return history[history.length - 1].content;
}
The key detail: when calling Claude Code, you omit the tools parameter entirely. It has its own tools and will use them as it sees fit, constrained by --allowedTools.
For a code review use case, Claude Code should be able to read and search the codebase but never modify it. The --allowedTools flag provides this boundary:
--allowedTools Read,Grep,Glob
This restricts the session to file reading, regex search, and glob matching. Write operations, shell commands, and other tools are unavailable. Combined with --permission-mode dontAsk, this creates a fully non-interactive, read-only session.
A subtle issue arises when your application is itself running inside Claude Code (e.g., during development). Claude Code sets a CLAUDECODE environment variable, and child Claude Code processes detect this to prevent infinite recursion.
The fix is to strip this variable before spawning:
const filteredEnv = { ...process.env };
delete filteredEnv.CLAUDECODE;
Without this, the subprocess will detect it's "nested" and may refuse to start or behave differently.
One practical advantage of the Claude Code provider: no API key management. Claude Code uses its own authentication (claude login), which persists in ~/.claude/.credentials.json or the macOS Keychain. From your application's perspective, the provider just works if the user has Claude Code installed and logged in.
This makes it the zero-configuration option. For API providers, you need to collect and securely store keys. For Claude Code, you inherit the user's existing session.
The trade-off is that auth errors are less structured. Parse stderr for authentication failures and surface a helpful message:
proc.stderr.on("data", (chunk) => {
const text = chunk.toString();
if (text.includes("not logged in") || text.includes("401")) {
// Surface to the user: "Run `claude login` to authenticate"
}
});
After running this in production, a few things I learned:
--permission-mode dontAsk is essential. Without it, Claude Code will pause and wait for interactive permission approval on stdin — which will hang your subprocess forever in a non-interactive context.toolCalls array in your CompletionResponse is always empty. This is by design, but make sure your downstream code handles it.--model claude-opus-4-6 (or any supported model) to override Claude Code's default. This lets users configure the model in your application's settings and have it flow through to the subprocess.The final shape is a provider adapter that looks like this:
| Concern | API Providers | Claude Code |
|---|---|---|
| Transport | HTTP | Subprocess (stdin/stdout) |
| Auth | API keys in env vars | claude login session |
| Tool handling | Application-managed loop | Internal to subprocess |
| Format | SDK-specific (OpenAI, Anthropic) | Line-delimited JSON |
| Sandboxing | Tool definitions you provide | --allowedTools flag |
| Token tracking | Full usage stats | Not available |
The provider abstraction hides all of this. Callers work with a single complete() function and never think about whether the response came from an HTTP API or a local subprocess.
This pattern generalizes beyond code review. Any application that wants to leverage Claude Code's agentic capabilities (file exploration, codebase understanding, multi-step reasoning) while maintaining a clean multi-provider architecture can adopt this approach. The subprocess boundary gives you sandboxing and isolation for free; the streaming JSON protocol gives you programmatic control.