spawn-agent: Use Any Local Coding Agent as a Vercel AI SDK Provider

April 30, 2026

|repo-review

by Florian Narr

spawn-agent: Use Any Local Coding Agent as a Vercel AI SDK Provider

spawn-agent turns any locally-installed coding agent — Claude Code, Codex, Cursor, GitHub Copilot CLI, Gemini, OpenCode, Factory Droid, Pi — into a LanguageModelV3 you can drop straight into the Vercel AI SDK. One package. Eight agents. One API surface.

Why I starred it

The Vercel AI SDK is already everywhere. streamText, generateText, useChat — these are the primitives most TypeScript AI work is built on. The problem is that agentic coding tools — the ones that can actually edit files, run shell commands, call MCP servers — aren't hosted LLMs. They're local CLI processes with their own auth, their own permissions model, and their own protocol.

spawn-agent bridges that gap. Instead of writing ad-hoc subprocess management every time you want to drive claude or codex programmatically, you get a provider that slots into any existing AI SDK workflow. The fact that version 0.0.1 shipped on April 26 — four days before I starred it — and already has 26 test files tells you someone thought this through before publishing.

How it works

The library sits on top of the Agent Client Protocol (ACP), an open JSON-RPC-over-stdio protocol for talking to coding agents. connect.ts is the low-level layer: it spawn()s the resolved binary, wraps stdin/stdout in a ndJsonStream from @agentclientprotocol/sdk, and hands back a ClientSideConnection. The stderr handling is careful — it keeps a capped ring buffer (appendCapped in utils/cap-buffer) rather than accumulating unbounded output, and it strips stdout noise via filterStdoutNoise before the JSON-RPC layer ever sees it.

Agent detection in src/detect.ts is pleasingly minimal:

const SUPPORTED_AGENTS: Record<SupportedAgentId, AgentMeta> = {
  claude: { binaries: ["claude"], displayName: "Claude Code" },
  codex: { binaries: ["codex"], displayName: "Codex" },
  copilot: { binaries: ["copilot", "gh"], displayName: "GitHub Copilot" },
  // ...
};

export const detectAvailableAgents = (): SupportedAgentId[] =>
  (Object.keys(SUPPORTED_AGENTS) as SupportedAgentId[]).filter((agent) =>
    SUPPORTED_AGENTS[agent].binaries.some(isCommandAvailable),
  );

isCommandAvailable is a synchronous PATH lookup. Simple, no runtime cost, no shell invocation needed.

The AgentAdapter interface in src/adapter.ts is the core abstraction:

export interface AgentAdapter {
  readonly id: string;
  readonly displayName: string;
  resolve(): Promise<ResolvedAdapter>;
  checkInstalled?(): Promise<boolean>;
  checkAuthenticated?(): Promise<boolean>;
}

Each agent gets its own file under src/adapters/. The Claude adapter (src/adapters/claude.ts) does something interesting: rather than invoking claude directly, it resolves the @agentclientprotocol/claude-agent-acp shim package and runs it via process.execPath (Node). That shim is what actually speaks ACP to Claude Code. Copilot and most others that have native ACP support skip the shim entirely.

The AI SDK integration lives in src/ai-sdk/spawn-agent-language-model.ts. SpawnAgentLanguageModel implements LanguageModelV3 — the current AI SDK provider spec — with both doGenerate and doStream. The session acquire/release pattern is what makes one-shot vs. stateful usage work:

// One-shot: new subprocess per call
const agent = await SpawnAgent.connect(target, options);
const sessionId = await agent.createSession();
return {
  agent, sessionId,
  isSessionBound: false,
  release: () => agent.close(), // shuts down the process
};

// Session-bound: subprocess stays alive
return {
  agent, sessionId,
  isSessionBound: true,
  release: async () => {}, // no-op; session outlives the call
};

The isSessionBound flag changes how the prompt is converted: bound sessions send only the last user message (to avoid re-sending conversation history the agent already has); unbound sessions send the full prompt.

Permission handling is a clean enum union that accepts either a preset string or a custom async function:

export type PermissionPolicy =
  | "auto-allow"
  | "auto-allow-once"
  | "auto-reject"
  | "stream"
  | ((request: RequestPermissionRequest) => Promise<RequestPermissionResponse>);

autoAllow and friends just find the right optionId from the ACP request options by priority (allow_always before allow_once, etc.). When you need human-in-the-loop, "stream" passes permission events through to the caller.

Using it

The one-shot path:

import { streamText } from "ai";
import { spawnAgent } from "spawn-agent";

const { textStream } = streamText({
  model: spawnAgent("claude"),
  prompt: "Refactor src/auth.ts to use the new session API",
});

for await (const chunk of textStream) {
  process.stdout.write(chunk);
}

For multi-turn work where you want the agent to remember context across calls, createSpawnAgentSession keeps a single subprocess and session alive using await using (TypeScript 5.2 explicit resource management):

import { generateText } from "ai";
import { createSpawnAgentSession } from "spawn-agent";

await using session = await createSpawnAgentSession("codex");

await generateText({ model: session.model, prompt: "list TODOs in this repo" });
await generateText({ model: session.model, prompt: "fix the highest priority one" });

When the using block exits, [Symbol.asyncDispose] calls agent.close(), which sends SIGTERM and waits up to 2 seconds before escalating to SIGKILL. That two-step kill is wired in connect.ts and isn't something you'd normally think to implement unless you'd seen agents that ignore SIGTERM.

MCP server config is a first-class option:

spawnAgent("claude", {
  mcpServers: [
    {
      type: "stdio",
      name: "filesystem",
      command: "npx",
      args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
    },
  ],
})

Rough edges

This is 0.0.1, published four days before I starred it. The test suite is genuinely impressive for that age — 26 test files covering concurrent sessions, buffer caps, wire fuzzing, auth retry, inactivity watchdog — but the library hasn't been stress-tested in production workflows yet.

A few things to watch:

  • Node 22+ only. The engines field is hard-fenced at >=22. That's reasonable (ReadableStream, WritableStream, and TransformStream globals are required), but it's a constraint if your project is on 20 LTS.
  • Claude requires a separate shim package. @agentclientprotocol/claude-agent-acp is a peer dependency you have to install separately — it's not bundled. The resolve() path in claude.ts will throw an AgentNotInstalledError with a helpful message, but it's an extra step the README buries in a table footnote.
  • "stream" permission mode has no built-in terminal handler. You get the raw RequestPermissionRequest events, but wiring up an actual interactive prompt is left entirely to you. The test at tests/terminal.test.ts shows the shape of the API, but there's no ready-made readline adapter.
  • Version 0.0.1. All commits on the main branch are from April 26, 2026. The changelog exists but only has one entry. The API surface could change.

Bottom line

If you're building TypeScript tooling that needs to drive local coding agents programmatically — CI scripts, orchestrators, automated refactoring pipelines — spawn-agent is the cleanest option available right now for plugging into an existing AI SDK setup. Wait for a few patch releases before putting it in anything load-bearing.

millionco/spawn-agent on GitHub
millionco/spawn-agent