Back to research

Using Claude Code as a Subprocess Provider for Automated Code Review

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 provider abstraction

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.

Finding the binary

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.

The streaming JSON protocol

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 typePurpose
assistantText content from the model, delivered incrementally
control_requestPermission prompts (file access, tool use)
resultFinal 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;
    }
  }
}
Note on stdin lifecycle

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().

The tool handling asymmetry

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.

Sandboxing

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.

Environment filtering

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.

Authentication model

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"
  }
});

Practical lessons

After running this in production, a few things I learned:


The resulting architecture

The final shape is a provider adapter that looks like this:

ConcernAPI ProvidersClaude Code
TransportHTTPSubprocess (stdin/stdout)
AuthAPI keys in env varsclaude login session
Tool handlingApplication-managed loopInternal to subprocess
FormatSDK-specific (OpenAI, Anthropic)Line-delimited JSON
SandboxingTool definitions you provide--allowedTools flag
Token trackingFull usage statsNot 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.