docs/frameworks/ACP.md
TL;DR: ACP lets OmniRoute spawn CLI agents (like Claude Code, Codex) as child processes instead of using HTTP APIs. This gives you "CLI-as-backend" transport.
ACP (Agent Client Protocol) is a "CLI-as-backend" transport for OmniRoute. Instead of intercepting HTTP API calls to AI providers, ACP spawns CLI agents as child processes and feeds prompts through their native interface.
| Benefit | Description |
|---|---|
| No API keys needed | Uses your existing CLI authentication |
| Native protocol | Uses each CLI's native input/output format |
| Auto-discovery | Detects installed CLIs on your system |
| 13 built-in agents | Pre-configured for popular CLI tools |
| Custom agents | Add your own CLI tools via settings |
| Process management | Handles lifecycle (spawn, send, kill) |
ACP supports 13 built-in CLI agents out of the box:
| Agent ID | Display Name | Binary | Protocol |
|---|---|---|---|
codex | OpenAI Codex CLI | codex | stdio |
claude | Claude Code CLI | claude | stdio |
goose | Goose CLI | goose | stdio |
openclaw | OpenClaw | openclaw | stdio |
aider | Aider | aider | stdio |
opencode | OpenCode | opencode | stdio |
cline | Cline | cline | stdio |
qwen | Qwen Code | qwen --acp | stdio |
forge | ForgeCode | forge | stdio |
amazon-q | Amazon Q Developer | q | stdio |
interpreter | Open Interpreter | interpreter | stdio |
cursor-cli | Cursor CLI | cursor | stdio |
warp | Warp AI | warp | stdio |
You can add your own CLI agents via settings. Custom agents support the same features as built-in agents.
# Example: Install Claude Code CLI
npm install -g @anthropic-ai/claude-code
# Verify installation
claude --version
ACP automatically detects installed CLI agents on your system. No configuration needed!
Once detected, ACP can be used as a transport for any supported provider. OmniRoute will automatically use ACP when the CLI is available.
┌─────────────────┐
│ OmniRoute │
│ (HTTP Proxy) │
└────────┬────────┘
│
│ spawn()
▼
┌─────────────────┐
│ Child Process │
│ (CLI Agent) │
│ │
│ stdin ◄──────┤ Send prompt
│ stdout ──────►│ Receive response
│ stderr ──────►│ Receive errors
└─────────────────┘
ACP uses stdio (standard input/output) for communication with CLI agents. The protocol is:
detectInstalledAgents()Detects all installed CLI agents on the system. Results are cached for 60 seconds.
import { detectInstalledAgents } from "@/lib/acp";
const agents = detectInstalledAgents();
// Returns: CliAgentInfo[]
interface CliAgentInfo {
id: string; // e.g., "codex", "claude"
name: string; // Display name
binary: string; // Binary name to spawn
versionCommand: string; // Version detection command
version: string | null; // Detected version (null if not installed)
installed: boolean; // Whether the agent is installed
providerAlias: string; // Provider ID in OmniRoute
spawnArgs: string[]; // Arguments to pass when spawning
protocol: "stdio" | "http"; // Communication protocol
isCustom?: boolean; // Whether this is a user-defined custom agent
}
getAvailableAgents()Gets only the agents that are installed and available for ACP.
import { getAvailableAgents } from "@/lib/acp";
const available = getAvailableAgents();
// Returns: CliAgentInfo[] (only installed agents)
getAgentById(id)Gets a specific agent by ID.
import { getAgentById } from "@/lib/acp";
const agent = getAgentById("claude");
// Returns: CliAgentInfo | undefined
setCustomAgents(agents)Sets custom agent definitions from settings.
import { setCustomAgents } from "@/lib/acp";
setCustomAgents([
{
id: "my-custom-cli",
name: "My Custom CLI",
binary: "mycli",
versionCommand: "mycli --version",
providerAlias: "my-provider",
spawnArgs: [],
protocol: "stdio",
},
]);
acpManager.spawn(agentId, binary, args, env)Spawns a new CLI agent process.
import { acpManager } from "@/lib/acp";
const session = acpManager.spawn("claude", "claude", ["--print", "--output-format", "json"], {
/* custom env vars */
});
// Returns: AcpSession
Allowed agent IDs: ["claude", "codex", "gemini", "qwen"]
acpManager.sendPrompt(sessionId, prompt, timeoutMs)Sends a prompt to a CLI agent and collects the response.
import { acpManager } from "@/lib/acp";
const response = await acpManager.sendPrompt(
"acp-claude-1234567890-abc123",
"What is 2+2?",
120000 // 2 minutes timeout
);
// Returns: Promise<string>
acpManager.kill(sessionId)Kills a session and cleans up.
import { acpManager } from "@/lib/acp";
const killed = acpManager.kill("acp-claude-1234567890-abc123");
// Returns: boolean
acpManager.getActiveSessions()Gets all active sessions.
import { acpManager } from "@/lib/acp";
const sessions = acpManager.getActiveSessions();
// Returns: AcpSession[]
acpManager.killAll()Kills all sessions.
import { acpManager } from "@/lib/acp";
acpManager.killAll();
interface AcpSession {
id: string; // Unique session ID
agentId: string; // Agent ID (e.g., "claude")
process: ChildProcess; // Child process handle
alive: boolean; // Whether the process is alive
stdoutBuffer: string; // Accumulated stdout buffer
stderrBuffer: string; // Accumulated stderr buffer
createdAt: Date; // Created timestamp
}
The AcpManager extends EventEmitter and emits the following events:
stdoutEmitted when the CLI agent writes to stdout.
acpManager.on("stdout", ({ sessionId, data }) => {
console.log(`[${sessionId}] stdout: ${data}`);
});
stderrEmitted when the CLI agent writes to stderr.
acpManager.on("stderr", ({ sessionId, data }) => {
console.error(`[${sessionId}] stderr: ${data}`);
});
exitEmitted when the CLI agent process exits.
acpManager.on("exit", ({ sessionId, code, signal }) => {
console.log(`[${sessionId}] exited with code ${code}, signal ${signal}`);
});
errorEmitted when the CLI agent process errors.
acpManager.on("error", ({ sessionId, error }) => {
console.error(`[${sessionId}] error: ${error}`);
});
ACP inherits all environment variables from the parent process and can be extended with custom env vars:
acpManager.spawn("claude", "claude", [], {
ANTHROPIC_API_KEY: "sk-...",
DEBUG: "true",
});
Each agent has default spawn arguments defined in the registry. You can override them:
acpManager.spawn("claude", "claude", ["--print", "--verbose"], {});
Default prompt timeout is 120 seconds (2 minutes). You can override:
await acpManager.sendPrompt(sessionId, prompt, 300000); // 5 minutes
Agent detection is cached for 60 seconds to avoid expensive filesystem scans. Force refresh:
import { refreshAgentCache } from "@/lib/acp";
refreshAgentCache();
ACP validates version commands to prevent command injection attacks:
const DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\r\n]/;
Version commands containing these characters are rejected:
; — Command separator& — Background process| — Pipe<, > — Redirection` — Command substitution$ — Variable expansion\r, \n — Line breaksACP validates that the version command binary matches the expected binary name (unless it's a custom agent).
Each ACP session runs in its own child process. The process is killed when the session ends or times out.
version command for each agent)Problem: acpManager.spawn() throws Unknown agent: <id>
Solution: Only these agents are allowed in spawn():
claudecodexgeminiqwenOther agents must be spawned manually or via custom agent definitions.
Problem: acpManager.sendPrompt() throws Session ${sessionId} is not alive
Solution: The session may have exited or been killed. Check session status:
const session = acpManager.getSession(sessionId);
if (!session?.alive) {
// Re-spawn the session
acpManager.spawn("claude", "claude", [], {});
}
Problem: acpManager.sendPrompt() throws ACP timeout after 120000ms
Solution: Increase the timeout:
await acpManager.sendPrompt(sessionId, prompt, 300000); // 5 minutes
Problem: detectInstalledAgents() doesn't find your CLI
Solutions:
claude --version manuallyProblem: ACP can't execute the CLI
Solutions:
chmod +x /usr/local/bin/claudeimport { acpManager, detectInstalledAgents } from "@/lib/acp";
// Detect installed agents
const agents = detectInstalledAgents();
const claude = agents.find((a) => a.id === "claude");
if (claude?.installed) {
// Spawn a new session
const session = acpManager.spawn("claude", claude.binary, ["--print", "--output-format", "json"]);
// Send a prompt
const response = await acpManager.sendPrompt(
session.id,
"Explain quantum computing in 100 words"
);
console.log("Claude's response:", response);
// Clean up
acpManager.kill(session.id);
}
import { acpManager, getAvailableAgents } from "@/lib/acp";
const available = getAvailableAgents();
// Try Claude first, fallback to Codex
let agentId = "claude";
if (!available.find((a) => a.id === "claude")) {
if (available.find((a) => a.id === "codex")) {
agentId = "codex";
} else {
throw new Error("No ACP-compatible CLI agent found");
}
}
const agent = available.find((a) => a.id === agentId)!;
const session = acpManager.spawn(agentId, agent.binary, agent.spawnArgs);
const response = await acpManager.sendPrompt(session.id, "Hello!");
acpManager.kill(session.id);
import { setCustomAgents, detectInstalledAgents } from "@/lib/acp";
// Register a custom CLI agent
setCustomAgents([
{
id: "my-llm-cli",
name: "My LLM CLI",
binary: "myllm",
versionCommand: "myllm --version",
providerAlias: "my-llm-provider",
spawnArgs: ["--format", "json"],
protocol: "stdio",
},
]);
// Now detectInstalledAgents() will include "my-llm-cli"
const agents = detectInstalledAgents();
manager.ts — Process lifecycle managementregistry.ts — Agent discovery and registrationindex.ts — Public API exports