Skip to content

Recipe — drive any ACP agent

Three pieces, all public API since 0.25.0 (SPEC §8C, ADR 0041):

  1. toolkit.asMcpServer() serves your toolkit on 127.0.0.1 behind a fresh bearer token and returns {url, token, close} (see the overview).
  2. loadACP({command, args, mcpServers, …}) spawns the agent, sends initialize and session/new with your mcpServers entries verbatim, and keeps the session warm. Put the served url and an Authorization: Bearer <token> header in an http entry; the agent connects and lists your tools. Per port: Go LoadACP, Python load_acp(ACPOptions(…)), Java AcpClient.start(…), C# AcpClient.StartAsync(…), Elixir Toolnexus.Acp.connect/2, Clojure toolnexus.acp/connect, Rust AcpClient::load(…).
  3. createInProcessClient({ generateStream: agent.generateStream }). Each turn is one session/prompt; every agent_message_chunk becomes a text delta the moment it arrives, agent_thought_chunk a thought event, and the agent’s own tool calls arrive as tool_call / tool_result events with via: "acp": reported to you, never executed by your loop.

The agent calls your tools itself, over MCP, inside its own loop. So the toolnexus client runs with an empty toolkit; toolnexus never sees a tool call it would execute a second time.

What the ACP client does for you on the wire: answers session/request_permission (first allow* option unless you pass a permission hook), answers agent requests it does not implement (fs/*, terminal/*) with -32601 so the agent never hangs on them, fails a pending request the moment the agent exits, and on close ends the agent’s whole process group (so npx-launched agents do not outlive you).

One file per language. Run it with the agent’s launch command as arguments, for example npx -y @agentclientprotocol/claude-agent-acp@0.100.0, copilot --acp, or opencode acp.

main.mjs
// usage: node main.mjs npx -y @agentclientprotocol/claude-agent-acp@0.100.0
import { createInProcessClient, createToolkit, defineTool, loadACP } from "toolnexus"
const add = defineTool({
name: "add", description: "Add two numbers.",
inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] },
run: ({ a, b }) => { console.log(`\n[tool] add(${a}, ${b})`); return `${a + b}` },
})
// 1. Serve YOUR toolkit on loopback, behind a fresh bearer token.
const toolkit = await createToolkit({ builtins: false, extraTools: [add] })
const server = await toolkit.asMcpServer()
// 2. Start the agent; mcpServers is sent to session/new exactly as written.
const [command, ...args] = process.argv.slice(2)
const agent = await loadACP({
command, args,
mcpServers: [{ type: "http", name: "toolnexus", url: server.url,
headers: [{ name: "Authorization", value: `Bearer ${server.token}` }] }],
permissionTimeoutMs: 300_000, // one turn of a real agent can take minutes
})
// 3. The agent runs the tools itself (over MCP), so the client's own toolkit is empty.
const client = createInProcessClient({ model: command, generateStream: agent.generateStream })
const empty = await createToolkit({ builtins: false })
try {
for await (const ev of client.stream("Use the add tool to add 17 and 25. Reply with just the sum.", { toolkit: empty })) {
if (ev.type === "text") process.stdout.write(ev.delta) // live text as the agent writes it
else if (ev.type === "tool_call" && ev.via === "acp") console.log(`\n[agent tool] ${ev.name}`)
else if (ev.type === "done") console.log("\nfinal:", ev.result.text)
}
} finally {
await agent.close() // ends the agent's whole process group
await server.close()
}
how
all eight recipes compiled and run on toolnexus 0.25.0 against a fake ACP agent (fake_acp_agent.py, stdlib Python: answers initialize / session/new, sends a session/request_permission, calls tools/list + tools/call on the served URL with the entry’s bearer header, streams two agent_message_chunks). Each printed [tool] add(17, 25) and final: The sum is 42.
JavaScript, Python, Go, Java, C#, Rust, live @agentclientprotocol/claude-agent-acp@0.100.0 on 2026-11-06: the agent found the tool ([agent tool] ToolSearch), called mcp__toolnexus__add over MCP with the bearer token, our add ran, and it answered 42, streamed
Elixir, Clojure, live not run live; same library wire, verified against the fake only

The recipe sources are the files that were run: site/recipes/in-process-client/.

By default an agent loads its own world into every session: instruction files, skills, plugins, its own MCP servers, hooks and ~30 built-in tools. That is 3k to 34k tokens per turn you pay for and did not ask for. Isolation turns it off while subscription login and your MCP server keep working. The big win is always turning off the agent’s built-in tools, which you do not want it running anyway.

agent (verified on) launch isolation knob context per trivial turn, default → isolated your MCP server gaps
Claude Code (@agentclientprotocol/claude-agent-acp@0.100.0, claude 2.1.318) npx -y @agentclientprotocol/claude-agent-acp@0.100.0 session/new _meta: {"claudeCode":{"options":{"settingSources":[],"strictMcpConfig":true,"plugins":[],"skills":[],"tools":[]}}} 34,073 → 3,368 works (HTTP verified live here); with tools:[] it calls your tool directly none found; subscription login unaffected. Without isolation it inherits your permissionMode setting
Copilot CLI (1.0.105) copilot --acp launch flags --disable-builtin-mcps --no-custom-instructions --available-tools=toolnexus-<tool>,… 16,343 → ~3,000 HTTP only: stdio servers in session/new are silently dropped. Tools appear as toolnexus-<tool> --available-tools needs your tool names at launch; it emits an Info: Disabled tools: … message chunk you should filter from the answer
opencode (2.0.23) opencode acp env OPENCODE_DISABLE_PROJECT_CONFIG=1 and OPENCODE_CONFIG_CONTENT={"tools":{"*":false,"execute":true,"toolnexus_*":true}} 11,859 → ~1,000 works, reached through its execute code-mode tool "*":false alone also hides your MCP tools: re-allow execute and toolnexus_*. An empty OPENCODE_CONFIG_DIR made it worse
Devin (2026.8.18) devin acp devin --agent-config <file> acp (keys system-instructions, allowed-tools, permissions, mcp-servers, extensions) 24,040 → 24,039 (no effect) ignores session/new mcpServers: write a .mcp.json in the session cwd, and hint the tool name in the prompt no knob measurably shrank context; MCP discovery was flaky
Codex (codex-acp 0.16.0) codex-acp -c mcp_servers={} --disable apps --disable plugins --disable hooks -c project_doc_max_bytes=0 -c skills.include_instructions=false ACP unmeasurable; codex exec 26,905 → 17,204 n/a every model returns 400 over ACP with a ChatGPT account. Use the one-shot recipe with codex exec instead
Gemini CLI (0.66.0) ACP mode per gemini --help from --help, unmeasured: --extensions none, --allowed-mcp-server-names toolnexus, --skip-trust not measured not measured not authenticated on the test machine; unverified
goose n/a profiles / GOOSE_* env per its docs not measured not measured not installed; unverified

All three are plain loadACP options, passed through untouched:

  • _meta (Claude): sessionMeta (Python session_meta, Go SessionMeta, Elixir :session_meta, Clojure :session-meta). Verified live with the Claude meta above (Extend it recipe, Python).
  • env (opencode): env, merged over the current environment (Go: "KEY=value" strings).
  • flags (Copilot, Codex, Devin): append them to args.

Most ACP agents advertise their models in the session/new result and let you switch per session:

  1. Read configOptions from the session/new result (your own ACP code: loadACP does not expose it) and take the entry with category: "model" (its values may be flat or grouped). Older agents use a top-level models object instead.
  2. Select with session/set_config_option { sessionId, configId: <that entry's id>, value }. Check that the returned currentValue is what you asked for; do not assume.
agent models over ACP (2026-11-05) other ways
Claude 13 values (default, opus, sonnet, haiku, full ids); setting haiku verified none from the CLI
Copilot only auto --model <id> / COPILOT_MODEL at launch
opencode 592 provider/model values; setting one verified opencode models
Devin 2 values; setting one verified --model, DEVIN_MODEL
Codex 5 values, all rejected for ChatGPT accounts codex debug models; -m on codex exec
Gemini, goose not measured

Vendor-specific hints, such as Copilot’s _meta.copilotUsage or opencode’s -free suffix, are raw data; filter on them in your own code if you need to. To pick a model, pass model to loadACP (Extend it); toolnexus does not list models.