Copse can drive an external ACP agent that runs locally on the same machine — or, with opt-in, on the remote host of an SSH workspace — Gemini CLI, Claude (via an ACP adapter), or anything else that speaks the Agent Client Protocol over stdio. Copse acts as the ACP client: it spawns the agent, hands it your workspace, and renders its activity in the normal chat UI. The external agent runs its own model loop (and brings its own auth), while Copse keeps ownership of the workspace and the approval UX.
The agent is a separate program — it is not bundled with Copse. Copse ships only
@agentclientprotocol/sdk(the client/protocol half). The agent half (the thing that wraps Claude/Gemini and speaks ACP) is installed by you, e.g.npm install -g @agentclientprotocol/claude-agent-acpfor Claude, then authenticated with its own command (e.g.claude setup-token). The Settings panel shows the exact install and sign-in commands per agent.
Status: client role, second slice (issues #264, #605). Terminals are not here yet — see Limitations.
- Each configured agent appears in the model picker under ACP agents as
acp:<id>. Selecting it routes the turn to the external agent instead of the built-in loop. - The agent's
session/updatestream is translated into the same chat chunks the built-in agent uses, so text and tool calls render normally. - Copse backs the agent's client callbacks with its own machinery:
fs/read_text_file→ a workspace-scoped read (paths are sandboxed to the open folder).fs/write_text_file→ the diff-approval queue. The agent's write is shown to you as a diff and is blocked until you approve or reject it.session/request_permission→ the normal approval dialog.
- Your configured MCP servers (stdio, plus http when the agent supports it)
are handed to the agent via
session/new, so it can mount them itself. The same trust and enable gating applies as for Copse's own connections. - A curated slice of Copse's own tools (workspace read/search/edit, Git,
shell/background commands, GitHub/CI, staged-diff visibility, archive
extraction, video stills, and web/browser) is offered as a per-session
localhost MCP server (the native-tool bridge, issue #602) when the agent
supports http MCP servers. Calls execute through the same
ToolRegistryas built-in model runs, so the normal path validation, diff queue, permission policy, sandbox escape, and approval dialogs apply. Disable with theacpNativeBridgeEnabledsetting. Bridged calls arrive on their own async chain, so the bridge rebinds the owning thread's identity (not its filesystem root) around each one — that is what letsread_archiveunpack into the right conversation's directory. Results carry images as MCP image content, sovideo_framesreturns the stills themselves rather than a manifest describing pictures the agent never received. Tools that need the native loop's transcript or orchestration state (ask_user, explore subagents, todos, memories) stay private;advisoris the exception, scoped to the whole ACP turn by agent-service. - How a tool call gets its name differs per agent, and no agent fills in
every field. Wire traces of three (captured with the
COPSE_DEBUG_ACP_UPDATESflag from #1659): Claude sends the qualified name (mcp__copse__run_shell) everywhere; Cursor titles the callMCP: toolwith empty arguments and reveals the real name only on the permission request; Codex sends no title on permission requests but does send the arguments. Copse treats the permission request as a patch over the display notification, so each agent's missing half is filled from whichever channel carries it. A consequence worth knowing: under Cursor, a bridged or MCP call that is auto-approved never produces a permission request, so it can keep the genericMCP: toollabel. - An abandoned bridged call does not lose its approval. MCP clients give
up on a silent tool call after their own timeout (Codex: roughly 300 s), and
a bridged
run_shellblocked on "Run outside sandbox?" is silent for as long as the user takes to answer. The bridge aborts the call with anAbandonedCallAbortreason; the approval service (approval.ts) treats that as a detach rather than a cancel — the prompt stays open, the call fails with a clear "approval is still pending, retry the exact same call" error (which reaches the agent when it cancelled over MCP; a dropped connection has no one to deliver it to), and the user's eventual answer is kept for ten minutes so the identical retry (same command, working directory and thread) reuses it without prompting again. A retry that arrives while the prompt is still open simply joins it. Turn end still dismisses the prompt, as before. - Because that title is also how Copse recognises its own bridged tools to
skip a duplicate approval prompt, the same per-agent spread applies: Cursor's
copse-gh_pr_list: gh_pr_listand Claude'smcp__copse__gh_pr_vieware both recognised. Codex sends no title at all, so its bridged calls still prompt twice — the bridge's own gate still enforces them, so this is noise, not a hole. - Known agents (the Claude, Gemini, and Cursor catalog entries) are spawned
under the workspace seatbelt on macOS when the project sandbox is active
(issue #590): writes confined to the workspace, home denied except the agent's
own config dirs, network limited to its declared endpoints (plus loopback for
the bridge). The confines come from the
KNOWN_ACP_AGENTScatalog at spawn time — no per-config copy — and the config's optionalsandboxfield overrides them (an object for custom confines,falseto opt out). The agent's shell children inherit the same confines, and approval prompts cannot override the agent's own shell sandbox. Sandboxed turns steer commands through the bridge'srun_shell: that reuses Copse's native permission decision and can run approved external commands outside the agent sandbox. Direct agent-shellgit push/npm installstill fail inside the sandbox. - Sessions persist per thread (issue #605): follow-up turns reuse the same
agent process and ACP session, so the agent keeps its own context (no
transcript replay) and background helpers it spawned keep running between
turns; their queued updates surface at the start of the next turn. Aborting a
turn sends
session/cancelbut keeps the session alive; sessions idle for 10 minutes are torn down.
Scope of the write guarantee: the diff queue only sees writes the agent routes through
fs/write_text_file. Well-behaved adapters (the Claude adapters, Gemini CLI) use it for file edits when Copse advertises the capability, but every agent keeps its own shell tool — ased -iorecho >from the agent's shell lands on disk directly, gated only bysession/request_permission(and not even that once you grant an "Always allow … execute" remember). Containment and detection of such writes are tracked in #590 and #591.
Open Settings → ACP agents. It scans your device when you open the tab and shows a chip row — one chip per agent — that hides each agent's details until you pick it, the same pattern as Settings → Providers. A dot marks the agents you've already added.
- Known agents lead the row (Gemini CLI, Claude Agent, Claude Code, Cursor,
Codex). Select one to see whether it's installed, the Install command to get
it, the Sign in command to authenticate it (e.g.
claude setup-token), and an Add to my agents button. - Once added, selecting an agent's chip opens its editor — change its model / permission mode, enable/disable it, or Remove it.
- The trailing Add agent chip is a custom form (id, title, command, args
one-per-line,
KEY=valueenv, enabled) for anything not in the shortlist.
Changes are saved immediately; reopen the model dropdown to see them. Re-scan device refreshes the installed/running status after you install something.
Opening the tab also runs auto-setup for curated npm presets (Claude, Codex):
missing adapters can be installed (with an approval), and an already-installed
adapter that is behind the npm registry latest can be upgraded the same way.
Upgrades use the npm beside the resolved binary so an nvm/prefix install stays
in that prefix. Cursor is never auto-installed (its installer is not npm).
Tip: you can Add a known agent before installing it — Copse stores the config now, and you run the shown Install/Sign in commands when ready.
External agents hold their own credentials, and those expire. When a turn fails
because the agent could not authenticate, Copse now says so in the agent's own
terms and offers to fix it: the chat message names the command that signs that
agent in again (claude /login, cursor-agent login, codex login, …), and a
prompt offers to open a shell in the Shells pane already running it. Finish
the sign-in there, then re-send your message — Copse cannot complete another
program's login flow for you.
The distinction matters because the commands differ. claude setup-token mints a
long-lived token for an agent that has never been signed in; an OAuth session
that has lapsed is renewed with claude /login. Copse tells the two apart from
the failure and names the right one.
Expiry is worth understanding rather than just re-running: an OAuth login
refreshes its own access token in the background, so a sandboxed agent that
cannot reach its provider's token endpoint will keep working until the current
token ages out and then fail with OAuth access token has expired. If that
happens repeatedly, check the Sandbox network audit card at the end of the
turn for a blocked host and add its domain to that agent's
sandbox.allowedDomains override. The card names the blocked destinations but
points at Copse's bridged GitHub, CI and shell tools first: those reach the
network from the host and need no allowlist entry, so widening the agent's own
egress is for hosts no bridged tool can stand in for — a token endpoint being
exactly that case. The Claude presets allow anthropic.com,
claude.ai, and claude.com (the console moved to platform.claude.com, which
is where an OAuth login refreshes) for exactly this reason.
Prefer the terminal? Run the standalone detector, which prints what's installed plus a ready-to-paste config block:
npm run detect:acp # or: node scripts/detect-acp-agents.mtsBoth the panel and the detector write the registeredAcpAgents setting. You can
also edit it directly (e.g. from the app DevTools console with
window.api.settings.set('registeredAcpAgents', [...])). Each entry matches the
AcpAgentConfig shape:
| Field | Required | Notes |
|---|---|---|
id |
yes | Lowercase slug (a-z, 0-9, -). The model value is acp:<id>. |
title |
yes | Shown in the model picker. |
command |
yes | Executable to spawn (absolute path or on PATH). |
args |
no | Arguments passed to the command. |
env |
no | Extra environment variables for the agent process. |
permissionMode |
no | ACP session mode to start each session in — see below. |
enabled |
yes | Only enabled agents appear in the picker. |
Example value:
[
{
"id": "gemini-cli",
"title": "Gemini CLI",
"command": "gemini",
"args": ["--acp"],
"env": { "GEMINI_API_KEY": "..." },
"enabled": true
}
]Once saved, pick Gemini CLI from the model dropdown (under ACP agents) and chat as usual. Open a folder first — the agent needs a workspace to act in.
In ACP client mode, whether the agent asks for approval is entirely the
agent's own policy — Copse just renders the session/request_permission
dialog it sends. ACP exposes that policy as session modes (e.g. Claude
Code's default / acceptEdits / bypassPermissions / plan). Set
permissionMode on the agent config (or pick one in the Permission mode
dropdown after Detect models) and Copse applies it with session/set_mode
right after the session is created — before the first prompt — so the agent's
own prompting is relaxed or tightened for the whole session. The dropdown is
populated from the modes the agent advertises in session/new; leave it on
Agent default to keep the agent's own behavior. An unknown/stale value
silently degrades to the agent's default rather than failing the turn.
Sandboxed Claude presets default to
acceptEdits. When a Claude preset runs under the workspace seatbelt (issue #590), the seatbelt already contains writes to the workspace and scratch dirs and the post-turn audit surfaces anything that bypassed the diff queue — so prompt-per-edit adds friction without adding safety. Copse therefore defaults those sandboxed sessions toacceptEditsunless you setpermissionModeyourself. Unsandboxed agents keep their own default prompting. Note that approving a request never lifts the seatbelt: a sandboxed agent that asks to touch a genuinely denied path (system/tmp, network) still fails withEPERMeven after you approve it.
Sandboxed Codex keeps
agentmode. Codex orchestrates tool calls from an opaque code-modeexeccell. Copse automatically accepts that cell only when the Codex preset is inside the native project seatbelt; concrete shell commands still include their command text and pass through Copse's ordinary shell gate. This avoids a prompt before every Copse MCP call without selecting Codex's broaderagent-full-accessmode for normal product sessions.
Beyond the model, ACP lets an agent advertise arbitrary session config
options on session/new — each a labelled list of values with an optional
category. The spec reserves model, model_config, mode, and
thought_level (reasoning/thinking effort), and is explicit that the category
is a UX hint: "It MUST NOT be required for correctness. Clients MUST handle
missing or unknown categories gracefully."
Copse therefore surfaces every select option the agent advertises, whatever
its category, in the composer's model picker:
- Open the picker and the agent's selectors are listed under the models —
Thinking effort · Medium,Mode · Default— each drilling into its choices. - Right-click the model picker for the same choices on one flat menu, without opening the model list.
- An option Copse has no label for still appears, using the agent's own
name.
Choices are stored per agent: config options under their configId in
configOptions, and a session mode in permissionMode (see above). Values are
validated against what the agent advertises before anything is sent — a level
that disappears with a model switch is logged and skipped, not forced.
Reasoning level applies with session/set_config_option and switches live:
picking a new one takes effect on the next turn of the same session, with no
respawn and no lost context (the same treatment as the model). A session mode
still needs a fresh session, so it is part of the pool fingerprint.
The list comes from Detect models in Settings, which caches what the agent advertised. An agent that has never been probed shows no selectors.
Agents differ in what they expose. In ACP v1 many surface permission modes via
modes/session/set_moderather than as amodeconfig option, and some encode reasoning effort as separate models instead of athought_leveloption — in which case it stays in the model list where it already was.
The spawned agent runs its own model loop, so Copse scrubs its own cloud LLM
provider keys (Anthropic/OpenAI/OpenRouter/…) from the agent's environment.
The agent must bring its own credentials: pass them explicitly through env
(as in the example above), or rely on the agent's own login/config. Non-LLM tool
tokens such as GITHUB_TOKEN are passed through.
ACP client mode and the built-in Copse agent loop (cloud/local models such as Fable or Sonnet) do not expose the same tool surface today:
| Capability | Built-in Copse (native model) | ACP client (acp:<id>) |
|---|---|---|
| Read/search files | read_file, search_codebase, search_code, … (default) or explore subagent (optional) |
External agent tools, plus equivalent bridged Copse tools |
| Edit files | write_file / str_replace → diff approval when needed |
fs/write_text_file or bridged edit tools → same queue |
| Shell / CLI | run_shell (structured; prefer dedicated tools for reads) |
Bridged run_shell / run_background (preferred); private shell remains sandboxed |
| Git / GitHub | git_*, gh_*, CI tools |
Equivalent bridged Copse tools |
| MCP servers | Copse ToolRegistry (mcp__* tools) |
Forwarded via session/new (agent mounts them itself) |
| Shared context-free native tools | Copse ToolRegistry |
Native-tool bridge (localhost MCP, approval-gated) |
| Skills, todo/plan tools | Copse ToolRegistry |
Not forwarded |
Default native behavior (Settings → Local models → Route reads and searches
through exploration subagents off) exposes direct read/search tools so native
models behave similarly to typical ACP coding agents instead of hiding those tools
behind explore or falling back to run_shell (grep, cat, …).
copse --acp (server mode) is the opposite direction: Copse exposes its full
native loop to an external ACP client.
This first slice intentionally leaves the following for follow-ups (issue #264):
- No terminals.
terminal/*requests are not backed yet. - Sessions are per-thread and idle-bounded. The agent process and its ACP
session persist across turns in a thread (issue #605), so the agent keeps its
own memory and background helpers survive between turns. A session idle for
10 minutes is reaped to free the process; agents that advertise
session/resume(Claude, Codex) restore the same session on the next turn without a transcript replay (issue #830). Agents without resume (or a failed resume), and config changes that force a new session, still get a one-shot history preamble. - Text only on input. Image attachments are dropped before the prompt is sent (the agent receives the text blocks).
- Native-tool bridge is http-only. Agents that support only stdio MCP servers don't get Copse's bridged tools this turn — a stdio shim is a possible follow-up (#602). Skills and todo/plan tools are not bridged.
- Sandboxing is macOS-only and catalog-scoped. Known presets (Claude,
Gemini, Codex, Cursor) ship a catalog
sandboxentry; custom agents spawn unsandboxed unless you addsandbox(allowedDomains,homeDirs) to theirregisteredAcpAgentsentry, or setsandbox: falseto opt a catalog agent out (#590). - SSH workspaces are opt-in. Off by default, ACP agents stay hidden from the
chat model picker and are rejected at session open on an SSH remote. Turn on
Settings → Experimental → ACP agents → Run ACP agents over SSH to spawn the
agent on the remote host (stdio over the existing ControlMaster connection)
instead of blocking ACP. The agent binary must be installed and authenticated
on that host; Copse does not forward local credentials. See
docs/plans/acp-over-ssh.md.
Not sure what a given agent actually supports? npm run probe:acp spawns each
installed agent, runs the initialize / session/new handshake (no prompt, no
tokens), and writes a support matrix comparing session resume, prompt content
types, MCP transports, modes, models, auth, and any _meta each adapter
tunnels. For write routing / permission payloads / mid-turn _meta under a real
turn, use npm run probe:acp:behavior (issue #832; spends tokens). See
docs/acp-capability-probe.md.
When an agent's tool calls show up with a useless label — the reported case is
Cursor rendering every MCP call as MCP: tool — the probes above only tell you
what survived parsing. To see what the adapter actually sent, run Copse with:
COPSE_DEBUG_ACP_UPDATES=1 npm startEvery inbound JSON-RPC message from the agent is then appended verbatim to
acp-debug.jsonl inside that thread's own folder, beside events.jsonl. The tap
sits on the transport, so each line is what the agent said before the ACP
schema drops unmodelled fields and before Copse normalizes the rest — an
experimental name, a vendor extension key, _meta, rawInput / rawOutput,
content, and locations all arrive intact. One JSON object per line:
Reproduce a case by starting a thread with the agent, asking it to call one MCP
tool, and letting the call finish. To get the file out, use Export thread
folder (ZIP) on the thread — acp-debug.jsonl is included — or read it
directly at ~/.copse/workspace/<projectId>/<threadId>/acp-debug.jsonl (under
$COPSE_WORKSPACE_DIR instead, if you set one).
The trace is deliberately unredacted. It contains your prompts, tool arguments and output, source code, absolute paths, and any secret an agent put in one of those — for example an MCP server token echoed back in
rawInput. Read the file before sharing it, prefer a scratch project when reproducing, and never set the flag by default. With the flag unset, no diagnostic file is created and nothing is serialized or written.
The one exception to "unredacted" is the header line, which records how Copse
spawned the agent rather than anything the agent sent. Argument values that look
like credentials are masked there — CLAUDE_CODE_OAUTH_TOKEN=<redacted> keeps
the variable name so you can still see which credential was passed. This matters
because some agents take their token as an argv entry, which would otherwise put
a live secret on line 1 of a file whose purpose is to be handed to someone else.
Every ACP payload below the header is still written verbatim.
docs/acp-capability-probe.md— the Tier-1 capability probe, Tier-2 behavioural probe, and support matrices (npm run probe:acp/npm run probe:acp:behavior).docs/plans/acp-client-support.md— the design notes and phased rollout.docs/plans/acp-over-ssh.md— opt-in ACP agents on SSH workspaces (Phase 1: remote spawn over ControlMaster stdio).- Agent Client Protocol — the protocol spec and list of supported agents.
{ "v": 1, "ts": "…", "dir": "in", "type": "notification", "method": "session/update", "msg": {/* verbatim */}, }