Skip to content

Extend it — your agent, isolation, models, permissions

Everything below is an option on the ACP client (loadACP and its per-port equivalents) from the ACP recipe, passed through to the agent untouched. The recipe at the end uses all of them, in all eight ports.

There is nothing to register. An ACP agent is a launch command plus a few pass-through options:

what changes per agent loadACP option
how to start it (copilot --acp, opencode acp, a binary path) command + args
isolation and auth that live in the environment env
per-session options the agent reads from session/new sessionMeta (sent as _meta)
vendor flags on initialize clientCapsMeta (sent as clientCapabilities._meta)
which model model
which of its tool calls to allow onPermission
anything ACP does not carry (Devin’s .mcp.json, a prompt hint) write it before loadACP(...), in the cwd you pass

A CLI with no ACP mode goes in the one-shot recipe the same way: it is just command + args. Keep a small table of your agents in your own config, for example:

const AGENTS = {
claude: {
command: "npx", args: ["-y", "@agentclientprotocol/claude-agent-acp@0.100.0"],
sessionMeta: { claudeCode: { options: { settingSources: [], strictMcpConfig: true, plugins: [], skills: [], tools: [] } } },
},
opencode: {
command: "opencode", args: ["acp"],
env: {
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify({ tools: { "*": false, execute: true, "toolnexus_*": true } }),
},
},
copilot: { command: "copilot", args: ["--acp", "--disable-builtin-mcps", "--no-custom-instructions"] },
}
const agent = await loadACP({ ...AGENTS.claude, mcpServers: [toolnexusEntry], permissionTimeoutMs: 300_000 })

By default an agent brings its whole world into your session: its instruction files, skills, plugins, its own MCP servers, hooks and built-in tools. That costs 3k to 34k tokens a turn and lets it do things you did not ask for. The knobs per agent, with measured numbers, are in the per-agent notes. Three rules from those measurements:

  1. Turn off built-in tools first. That is the 10x win (Claude 34k → 3.4k, Copilot 16k → 3k, opencode 12k → 1k). “Settings off” alone saves far less.
  2. Re-allow your own tools explicitly when the knob is a deny-all (opencode "*": false also hides MCP tools unless you allow toolnexus_*).
  3. Do not swap HOME / config dirs to isolate. Subscription logins live there or in a keychain entry keyed by it; it breaks login and, for opencode, measured worse.

Check it worked: ask a trivial question and compare the agent’s own token count with and without the knob. onUpdate sees every raw session/update, including usage_update; the recipe below logs those to stderr.

Pass model to loadACP. toolnexus sends it unchanged after session/new, first as session/set_config_option {configId: "model", value} and, if the agent rejects that, as session/set_model {modelId}; if both fail, loadACP fails. It never lists or validates models. Verified live: Claude Code with model: "haiku" reported claude-haiku-5-5 in its usage_updates.

To list what an agent offers, read the session/new result yourself: configOptions, the entry with category: "model" (values flat or grouped), or a top-level models object on older agents. loadACP does not expose that result, so this is a few lines of your own ACP code (the per-agent notes have what each agent returned). Always check what you got: some agents (Copilot) only offer auto, and Codex lists models a ChatGPT account cannot use.

ACP agents ask before running a tool: a session/request_permission request with options, each with a kind (allow_once, allow_always, reject_once, reject_always). With no hook, the client picks the first allow* option, which is right only when the agent has nothing but your tools. A policy is the onPermission hook: it gets the raw request params and returns the optionId to select; returning nothing answers with the first reject* option (or cancelled when the agent offered none).

Other policies follow the same shape: ask a human through your UI and reply when they answer (the agent waits, up to the turn timeout), log every request, or route the decision to a judge. Isolation is still the better first line: an agent with no shell tool cannot ask to run one.

The recipe: isolation, model and a permission policy

Section titled “The recipe: isolation, model and a permission policy”

Claude Code’s isolation _meta, the model from ACP_MODEL when set, a policy that allows only tool calls whose title names one of your toolkit’s tools, and usage_updates logged to stderr.

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 }) => `${a + b}`,
})
const toolkit = await createToolkit({ builtins: false, extraTools: [add] })
const server = await toolkit.asMcpServer()
// Permission policy: allow tool calls whose title names one of your tools, reject the rest.
// Return an optionId to pick it; return undefined to answer with the first reject option.
function allowOnly(names) {
return (params) => {
const title = params.toolCall?.title ?? ""
const ok = names.some((n) => title.includes(n))
return params.options.find((o) => o.kind.startsWith(ok ? "allow" : "reject"))?.optionId
}
}
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}` }] }],
// Agent-specific knobs are opaque data: this one isolates Claude Code (see the per-agent notes).
sessionMeta: { claudeCode: { options: { settingSources: [], strictMcpConfig: true, plugins: [], skills: [], tools: [] } } },
model: process.env.ACP_MODEL, // e.g. "haiku"; passed through, never validated
onPermission: allowOnly(toolkit.tools().map((t) => t.name)),
onUpdate: (u) => { if (u.update?.sessionUpdate === "usage_update") console.error("[usage]", JSON.stringify(u.update)) },
permissionTimeoutMs: 300_000,
})
const client = createInProcessClient({ model: command, generate: agent.generate })
try {
const result = await client.run("Use the add tool to add 17 and 25. Reply with just the sum.", { toolkit: await createToolkit({ builtins: false }) })
console.log(result.text)
} finally {
await agent.close()
await server.close()
}

Verified on 0.25.0 against the fake ACP agent in all eight ports (the agent received the _meta, the policy answered allow, The sum is 42.), and live in Python against @agentclientprotocol/claude-agent-acp@0.100.0 with ACP_MODEL=haiku.

ACP has a session/cancel notification ({ method: "session/cancel", params: { sessionId } }, no id): the pending session/prompt resolves with stopReason: "cancelled" and the process and session stay warm. Go and Rust send it for you: cancel the run’s ctx (Go GenerateStream / GenerateCtx) or fire the cancellation token (Rust). In the other ports the ACP client does not send it today; to stop a turn there, close() the agent and start a new one.

The ACP client’s generateStream already maps agent_thought_chunk to thought events and the agent’s tool_call / completed tool_call_update to tool_call / tool_result events with via: "acp"; the stream consumer gets them, the loop never executes them. Everything else (plan, usage_update, available_commands_update, …) reaches onUpdate as raw params.