Selective tool proxy
Choose, per tool, whether Claude Code or opencode executes it.
This is the core feature.
By default, the plugin proxies Bash, Edit, Write, WebFetch, and Task. It disables Claude’s corresponding built-in tool and exposes an equivalent through an in-process MCP server. Claude calls the MCP version, which blocks until opencode runs the tool through its own executor and permission system.
Default proxied tools
proxyTools value |
Claude built-ins disabled | Proxy MCP tool exposed |
|---|---|---|
"Bash" |
Bash |
mcp__opencode_proxy__bash |
"Edit" |
Edit, MultiEdit |
mcp__opencode_proxy__edit |
"Write" |
Write |
mcp__opencode_proxy__write |
"WebFetch" |
WebFetch |
mcp__opencode_proxy__webfetch |
"Task" |
Agent |
mcp__opencode_proxy__task, mcp__opencode_proxy__task_batch, and on a host that runs background subagents mcp__opencode_proxy__task_status, mcp__opencode_proxy__task_cancel |
"Question" |
AskUserQuestion |
mcp__opencode_proxy__question |
"Compress" |
none | mcp__opencode_proxy__compress |
OpenCode-native subagents
Task is proxied by default. The proxy disables Claude CLI’s Agent tool and emits an unexecuted task call; it does not register a replacement task tool. OpenCode’s built-in TaskTool remains responsible for permission checks, creating or resuming the child session, selecting the configured subagent, and foreground/background lifecycle.
- Permissions: the calling agent’s
permission.taskrule applies to the targetsubagent_type. Granttask: "allow"on agents that should delegate without a prompt; anaskordenyrule remains authoritative. The plugin never bypasses this decision. - Resume: pass the child session ID back as
task_idto continue that subagent session. Omit it to create a fresh child. - Nested tasks: current opencode defaults
subagent_depthto1, so a first-level child cannot launch another child. Increase top-levelsubagent_depthto permit deeper nesting, and explicitly grantpermission.taskon every subagent that should delegate; opencode otherwise adds a task deny to spawned subagent sessions. - Background: see Background subagents below. Foreground is the default.
- Several at once:
mcp__opencode_proxy__task_batchtakes atasksarray of ordinary task inputs and runs them concurrently. It exists because Claude Code sends MCP requests one at a time: when the model emits twotaskcalls in one response, the second only leaves the CLI after the first has returned (measured live, 2026-09-06), so “launch two subagents” was always serial. The plugin turns onetask_batchcall into N opencodetaskcalls inside a single tool boundary, which opencode executes in parallel, then hands the model every result together, labelled in task order. Same permissions, same no-deadline default, samesubagent_typelist. Enabled wheneverTaskis proxied. Designed and first implemented by @broskees on his fork.
Steering models to it. Headless Claude Code CLIs expose no Agent/Task
dispatch tool of their own (verified on 2.1.211), while they do expose
TaskCreate, a todo tool. So “use a subagent” requests get mis-resolved:
a todo appears, nothing runs, and the model may still narrate a successful
dispatch. Two spawn-time countermeasures prevent that. The plugin injects
opencode’s live agent-type list into the task proxy description (so the model
picks a real subagent_type instead of guessing a Claude Code name like
general-purpose, and doesn’t grep configs to check a subagent exists), and
appends a system-prompt note naming
mcp__opencode_proxy__task as the only dispatch path, with the ToolSearch
recovery step for harnesses that defer MCP tool schemas. Both apply per Claude
process at spawn, and provider options are read once at opencode startup, so
proxyTools changes need a full opencode restart.
Two neighbours of this section live elsewhere now: dispatching a subagent without blocking the conversation is Background subagents, and the proxy’s own HTTP endpoint and the files it writes are Scratch files and security.
Closing a tool with no proxy
proxyTools only reaches built-ins the plugin can replace. A built-in with no opencode equivalent, NotebookEdit today and whatever Claude Code ships next, stays enabled and unmediated no matter what you put in that list. extraDisallowedTools names them directly:
"options": { "extraDisallowedTools": ["NotebookEdit"]}These go straight to claude --disallowedTools, so use Claude’s tool names rather than opencode’s. There is no replacement: the capability goes away rather than being routed through opencode, which is the point, but the model then has to work without it.
Unknown entries in proxyTools are logged as a warning at spawn rather than passing silently, so a typo shows up as “ignoring unknown proxyTools entries” in the plugin log instead of quietly leaving the matching built-in unmediated.
Context compression
"Compress" is off by default. Add it when you run a harness that expects the model to manage its own context (opencode-dcp injects exactly those instructions), and the plugin exposes mcp__opencode_proxy__compress:
"options": { "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task", "Compress"]}It is the one proxy tool opencode never sees. The call is answered inside the plugin: the model passes a summary, the plugin stores it, and the turn continues normally. At the start of the next turn the Claude Code session is discarded and a fresh claude starts with that summary prepended to its system prompt, and nothing else. The earlier conversation is not replayed, so a thin summary means real lost context. The reset waits if the incoming turn is carrying tool results for the running process.
Without it, the appended system prompt tells the model that compress is unavailable and to ignore instructions that ask for it, which is the right answer when nothing implements it.
The round trip is verified live (Claude Code 2.1.263, opencode 1.18.31, haiku): the model called mcp__opencode_proxy__compress with a build identifier in its summary, the plugin logged compress stored summary; session resets next turn, the next turn logged compress reset: dropped claude process and session id and spawned a second claude, and that fresh process answered with the identifier it could only have read from the summary in its system prompt.
Only those seven values are actually proxied; anything else you put in proxyTools is ignored. Proxying Edit also disables MultiEdit, because opencode has no batched-edit equivalent, so Claude is forced to fan out into single Edit calls that each flow through the permission UI. The "Question" proxy is version-gated on opencode’s built-in question tool: on builds that lack the registry entry the def is silently dropped (a forwarded call would otherwise render as ⚙ invalid), so add it only on opencode versions that ship the question tool.
Without "Task" in proxyTools, Claude’s built-in Agent tool stays enabled and Claude orchestrates subagents internally with no opencode child-session visibility. To opt out of all proxying, including Task, use an explicit empty list:
"options": { "proxyTools": [] }Forwarding opencode’s own tools
proxyTools names the tools this plugin ships defs for, and MCP-backed opencode tools can be routed with proxyOpencodeMcpTools. Neither covers a tool that another opencode plugin declares directly: it belongs to no MCP server, so the automatic match (<server> or <server>_<tool>) skips it and the model is never offered it. opencode-dcp’s compress is the case that matters in practice, because DCP then injects “MAX CONTEXT LIMIT REACHED … You MUST use the compress tool now” reminders that the model has no way to act on.
proxyOpencodeTools is the explicit allowlist. Empty by default:
"options": { "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task"], "proxyOpencodeTools": ["compress"]}Names are opencode’s tool ids as client.tool.list() reports them, matched case-insensitively. An unknown name is skipped with a warning rather than failing the spawn. Forwarded tools use the same broker as every other proxy tool, so how a proxied call ends applies to them unchanged: abort, orphan sweep, session deletion and child exit all release them.
This is deliberately not automatic. A forwarded tool executes inside opencode with the calling agent’s permissions, so which ones cross over is your decision, not the plugin’s.
The compress name collision. Two different tools want it: DCP’s, which rewrites opencode’s transcript, and this plugin’s, which resets the Claude Code session. They compress different windows, and after a DCP compress the live claude process still holds its full context until something restarts it. If you enable both, the plugin’s own tool keeps the name and the forwarded one is dropped with a warning in the log:
WARN: proxyOpencodeTools entry dropped: a proxy tool already holds that name, and it keeps it {"collided":["compress"]}Pick one. The appended system prompt describes whichever is actually reachable, so the model is told the right semantics either way.
Verified live on Claude Code 2.1.263 and opencode 1.18.31 with DCP loaded: the plugin logged forwarding opencode tools through the proxy {"tools":["compress"]}, started the proxy with tools: ["bash","compress"], received proxy-mcp tool call received {"toolName":"compress"}, queued it through the normal broker, and DCP really ran, returning Compressed 3 messages into [Compressed conversation section]. One wrinkle worth knowing: DCP’s compress rewrites opencode’s message history mid-turn, which makes opencode abort the provider stream at that tool boundary. The pending call is released normally and the result still reaches the model on the next step as text, so the turn completes, but you will see one abort between proxy tool boundaries line in the log each time.
Trimming unsatisfiable context reminders
DCP anchors its nudges into message text as <dcp-system-reminder> blocks, so each one is re-sent with every message that carries it. If no compress tool is reachable they are an order the model cannot follow, and the plugin already tells it to ignore them. stripContextReminders: true stops paying for them too:
"options": { "stripContextReminders": true }Off by default. It removes those blocks from user and assistant text before the transcript reaches the CLI, including the fresh-session rebuild, where every anchored reminder would otherwise replay at once. It leaves opencode’s own <system-reminder> blocks alone: those are opencode’s instructions to the model, not an unsatisfiable order.
It switches itself off whenever compress is named in proxyTools or proxyOpencodeTools, since the reminder is then something the model can act on. The check is on configuration, so a name that is configured but missing from opencode’s registry still counts as reachable and nothing is stripped, which errs toward keeping the reminder.
What you get with proxying on
- opencode’s permission prompts for every Bash/Edit/Write/WebFetch call. The default
--dangerously-skip-permissionsis still passed toclaude, but it only governs Claude’s own built-in tools; a proxied call is executed by opencode and answers to opencode’s rules instead. Built-ins that are neither proxied nor listed inextraDisallowedToolsdo run under that flag. - opencode’s audit log captures the calls.
- Per-tool policy rules in opencode apply.
What you give up
-
A small per-call latency hop through
127.0.0.1:<random>/mcp. -
Batched-edit ergonomics: with
Editproxied, Claude can no longer useMultiEdit, so a refactor that would have been one tool call becomes N singleEditcalls. -
One extra Claude Code API call per
claudeprocess, and it is aToolSearch. A proxied tool reaches the model as an MCP tool, and Claude Code 2.1.280 defers MCP tools behind its ownToolSearchtool, so before the first proxied call of a session the model spends one request finding the tool. Claude’s built-inBashis never deferred, so an unproxied tool goes straight to the call.Measured on 2.1.280 with
claude-haiku-4-5, three runs a side, oneechocommand: 3 CLI API calls withBashproxied against 2 with the CLI running it, and roughly twice the cache reads. It is paid once per process, not once per call: the same task with two sequential commands measured 4 calls against 3, with a singleToolSearcheither way. It is also not a function of how many tools you have, since a run withstrictMcpConfig: trueand 28 tools still spent it. SettingENABLE_TOOL_SEARCH=0does remove it, and costs far more than it saves (all ~164 tool definitions then sit in every prompt, which measured 2.5 to 4 times the total cost and tripped a compaction), so that is not a fix and the plugin does not do it.ToolSearchis one of Claude’s internal tools, so you never see the call, only the cost. Full numbers:docs/agents-history.mdunder#g166.
Per-tool proxy timeouts
Read How a proxied call ends first: a deadline is a backstop on top of the events that actually end a call, not the thing that decides it.
Deadlines still exist, as an explicit backstop rather than the mechanism that decides when a call is over. If a tool with one has not been resolved within that many milliseconds, the call is rejected and Claude receives a timeout error.
A deadline does not count time opencode is still spending on the call. When it passes, the plugin asks opencode whether the session is still busy. If it is (a permission prompt waiting for your answer, or the tool itself still running), the call keeps waiting and is checked again every minute. The deadline only applies once opencode is idle, or when opencode cannot be asked. Before this, answering a permission prompt after ten minutes meant Claude had already been told the command timed out. Your late approval then cancelled Claude’s next action, which it reported as you rejecting it. Resolved per tool, most-specific layer winning:
- flat default, 10 min (matches Claude CLI’s own Bash ceiling)
- per-tool default:
task/task_batch: none,question: 30 min, everything else: 10 min - your
proxyToolTimeoutMsoverride (case-insensitive key; a positive value replaces the default,0removes the deadline, anything else is ignored) - for
bashonly, the call’s owninput.timeout: the proxy never undercuts a build the caller explicitly asked to run long (max(resolved, input.timeout)), and a positiveinput.timeoutrestores a deadline thatbash: 0removed
question keeps 30 minutes because it blocks on a human reading a form, and a form nobody answers is not an event. A positive task override restores a wall-clock backstop for operators who want one; if it fires, the error tells Claude not to “schedule a wake-up”: that is a Claude Code affordance which cannot fire in this headless/proxy context, so deferring silently loses the work.
Two watchdogs are a different thing again and are unchanged: the start watchdog (90 s of complete silence after a turn is written, respawn then error, see CLAUDE_CODE_START_WATCHDOG_MS) and the wire-inactivity watchdog (60 s of silence after content, see CLAUDE_CODE_RESULT_FALLBACK_MS; when it fires the reply gets a ▌ **stream timeout:** note so the turn does not just stop). Those exist because a process that is alive but wedged emits no event to listen to, and a proxy call is never what they are waiting on: a CLI parked inside a proxied tool is producing nothing on purpose, and both watchdogs know that.
If Claude nevertheless abandons the HTTP call, the plugin preserves narration emitted while opencode was running the tool, renders it on return, and delivers the late completion as a plain-text continuation naming the original call. It tells Claude not to run the tool again. A silent post-tool continuation gets one resumed-process retry, preserving the original model, account, effort, and proxy configuration; a second failure ends with an error rather than an indefinite hang. Buffered narration is capped at 500 lines and 2 MiB, with a warning if output was dropped.
"options": { "proxyTools": ["Bash", "Edit", "Write", "WebFetch", "Task"], "proxyToolTimeoutMs": { "Task": 5400000, "bash": 1800000 }}WebSearch routing
Claude Code ships a built-in WebSearch tool. The webSearch option controls who actually executes those calls:
webSearch value |
Behavior | When to use |
|---|---|---|
"claude" (default) |
Claude CLI runs WebSearch internally via Anthropic. Zero setup, no extra cost, no API key. The query is shown in the transcript as a > Web search: line (opencode has no WebSearch tool registry entry, so a raw tool row would render as ⚙ invalid). |
Most users. |
"<opencode-tool-name>" (e.g. "websearch_web_search_exa") |
Forward to that opencode-side tool with executed:false. Requires the corresponding MCP server to be configured in opencode (e.g. exa-mcp-server). |
You want a specific search backend (Exa, Tavily, Brave) and have the MCP wired up in opencode. |
"disabled" |
WebSearch is added to --disallowedTools so the model can’t call it. |
Compliance/security scenarios where outbound search isn’t allowed. |
"options": { "webSearch": "websearch_web_search_exa" }Trade-offs
- Claude-side execution: free with your Claude usage, no API key, but no opencode visibility into queries/results, no caching/rate-limit hooks.
- opencode-side execution: choose any backend, queries flow through opencode’s audit/policy/cache, but costs money (search APIs are paid) and adds a network hop.
- Some Claude-specific tool features stay on the built-in side (notably
MultiEdit, see the note above).
