A web transport for the Agent Client Protocol.
ACP standardises how a coding agent talks to the editor driving it — streaming messages, tool calls, diffs, terminals, permission prompts. In practice it has exactly one transport: newline-delimited JSON-RPC over a subprocess's stdio. Zed, the reference implementation, constructs no other. A browser cannot spawn a subprocess, so ACP has never had a web client.
This adds one.
Browser (React + Vite + TS) Rust server Agent subprocess
───────────────────────────── ───────────────────── ──────────────────
ACP *client* role mjx-acp-server claude-acp / kilo /
@agentclientprotocol/sdk ◄── WS ──► relay + client-side ◄─stdio─► gemini / codex /
createWebSocketStream capability host mjx-mock-agent
One WebSocket = one ACP connection = one agent subprocess. Both hops carry the same JSON-RPC frames, so the browser is an ordinary ACP client and the agent an ordinary ACP agent. Neither knows the other is remote.
./scripts/demo.shBuilds both halves, starts the server on http://localhost:4321, and opens it. Needs no credentials and no network.
Pick Mock Agent and ask it anything. It scripts a full turn — a thought
block, streaming text, a file read, a plan, a diff, a permission request, a live
terminal, and a form to fill in — so every UI surface is exercised out of the
box. It also starts with one conversation already behind it, so History has
something to open before you have had a second one. It genuinely
reads and rewrites demo/workspace/stats.js and genuinely runs node --test
against it, so the green test run at the end is real, not staged.
Then pick a real agent from the same list.
The server is a relay with exactly four interception points. Everything else passes through untouched — including frames it cannot classify, because a relay that only forwards what it understands breaks the day either peer speaks a newer protocol version.
1. initialize, on its way to the agent. The browser declares what it can
do, which is very little: it has no filesystem and cannot start a process. The
server merges in fs.readTextFile, fs.writeTextFile and terminal before
forwarding, because those live on its side. Without this the agent would never
call them at all.
2. fs/* and terminal/*, on their way from the agent. Never forwarded.
The server answers them against the workspace and replies directly. Files are
read and written through a jail that canonicalises paths first, so .. and
symlinks are resolved before the containment check. Terminals get a real PTY,
because test runners and build tools change their output when stdout is not a
TTY, and the point is to show what a terminal would show.
3. The MCP servers, added on the way to the agent. mcpServers is the ACP
client's field to fill, and the browser sends it empty — the servers are
configured in mjx.toml, on this side. So the server merges them into
session/new, session/load, session/fork and session/resume as they pass,
by name, so a client that configures its own keeps it. See
Configuration.
4. mcp/*, on its way from the agent — when, and only when, something is
configured transport = "acp". Then this server spawns the MCP server and
holds it, and the agent reaches it through mcp/connect and mcp/message
without ever being told the command or its environment. With nothing configured
these are forwarded like everything else, which is honest: the browser does not
implement them.
That leaves the UI with a blind spot — it never sees the traffic the server
answered — so every outcome is mirrored back over an _mjx/* extension
notification. ACP routes any _-prefixed method to its extension mechanism, so
these coexist with the protocol rather than colliding with it.
| Method | Direction | Carries |
|---|---|---|
_mjx/agent/info |
→ browser | which agent started, its command line and cwd, and the id to resume it with |
_mjx/agent/stderr |
→ browser | the agent's diagnostics, so a crash is visible |
_mjx/terminal/created |
→ browser | a terminal exists, before any of its output |
_mjx/terminal/output |
→ browser | incremental PTY bytes, base64 |
_mjx/terminal/exit |
→ browser | exit code or signal |
_mjx/fs/wrote |
→ browser | a file changed, with before and after |
_mjx/inspector/frame |
→ browser | a frame the browser never saw, for the inspector |
_mjx/session/replay |
→ server | give me the thread state you folded |
_mjx/session/turn_ended |
→ browser | a turn started on an earlier socket has finished |
_mjx/connection/taken_over |
→ browser | another tab attached; this socket is closing |
_mjx/* is between the browser and this server only. An agent never receives
one.
Closing a tab is not quitting an editor, so the agent does not die with the
socket. It keeps running, along with the thread the server folded and any
terminals it started, and the browser is given an id on _mjx/agent/info to
come back with as ?resume=. On the way back the server answers initialize
and session/new from what the agent said the first time — so the reload gets
the session it already had rather than a second one beside it — and the browser
replaces its thread from _mjx/session/replay.
Three consequences worth knowing:
- A turn that was running keeps running. A question the agent asked and the departed browser never answered is put to the browser that replaces it, which is what stops a reload during a permission prompt parking the agent forever.
- A second tab takes over rather than being refused. On a reload the new socket can arrive before the old one's close has been processed, so refusing would make an ordinary refresh fail. The displaced tab is told, and offers to take it back.
- An open form comes back twice, and lands once. A pending
elicitation/createis both carried in the replayed thread and re-asked over the new socket. Neither alone works: the thread is what makes the question and its answer part of the conversation, and the re-ask is what makes it answerable, since a browser cannot respond to a request the connection it is holding never received. The browser matches them by JSON-RPC id. - Anything the handshake announced is announced as of when it started.
Session modes and config options — the model selector among them — arrive on
the
session/newresponse, and after a reload that response is a recording. So the server folds them into the thread as well, and the sidebar reads the model actually in effect from the replay rather than from the handshake.
An agent nobody comes back to is reaped after [server] resume_ttl_secs, five
minutes by default; 0 turns the whole thing off. GET /api/connections shows
what is currently pooled — without the ids, since a connection id is the
capability to talk to a running agent and nothing authenticates that endpoint.
An agent that keeps its conversations can offer them back. claude-acp and
kilo advertise loadSession and sessionCapabilities: { list, delete, fork, resume, close }; most of the registry advertises none of it. So the history
drawer is built from what the connected agent said in initialize and nothing
else — every button is one capability, and an agent that lists but cannot fork
gets no Fork.
The relay forwards all six untouched. The session lifecycle is not intercepted:
what
the server adds is the fold, because it keeps a thread per session and a
session/load replays a whole conversation back as session/update
notifications. Those arrive during the call, before its response, so both
sides empty that session's thread before the request goes out. Otherwise the
replay lands on top of what was already there — every message twice, and three
times after the next load.
Two smaller consequences:
- The tab remembers which conversation it is looking at. A reload still has
its
session/newanswered from the recording made when the connection started, which is what makes resuming transparent to an ordinary ACP client — but that is no longer the session on screen once one has been opened from the history, and the relay has no way to know. A remembered session the server has no thread for falls back to the recorded one. session/updateis filtered by session id in the browser. A fork leaves the original running, so more than one conversation can be live on a connection, and folding all of them into the thread on screen would put one conversation's messages in another.
A session belongs to the directory it was started in, and an agent's history
spans every project it has been used in — so a load, resume or fork carries
that session's cwd, not this connection's. Sending the wrong one is refused
with -32002, which reads as if the conversation were missing when it is only
somewhere else. Opening one from another directory works; the drawer marks it,
because the workspace the server reads and writes through is still this
connection's, so files outside it will be refused.
session/delete removes a conversation; session/close only frees what it is
holding and leaves it listed. session/resume picks one back up without
replaying it, so the thread comes from the server's fold rather than the agent.
Both sides fold the session/update stream into a renderable thread: the server
in crates/mjx-acp-thread (a GPUI-free port of Zed's acp_thread) and the
browser in web/src/acp/thread.ts. The rules are subtle — when two streamed
chunks are the same message, when a tool call is new rather than an update,
which fields a partial update may overwrite — so both are folded through the
same recorded turn, fixtures/session-updates.jsonl, and asserted against the
same numbers. Two implementations of the same rules are only worth having if
something notices when they disagree.
Two entry kinds cannot be covered that way, because they arrive as requests
rather than as updates and so can never appear in a recorded notification
stream. For elicitations the Rust model writes
fixtures/session-elicitations.json instead and the browser reads it back
through its replay adapter, which pins the serialization the same way.
MentionUri — what an @-mention points at — is ported twice for the same
reason, and pinned the same way by fixtures/mention-uris.json. It is not a
folding rule but a parser, and the place two ports of a URI parser drift is
percent-encoding: Rust's Url::set_path and the WHATWG pathname setter do not
escape the same set, so the browser builds its URIs by hand and the fixture
holds the two to the same 49 answers.
Anything in the ACP registry that
ships via npx or uvx is offered automatically — 39 agents at time of
writing. The picker shows the command it will run and explains what it can't
start rather than hiding it.
| Agent | How it starts | Setup |
|---|---|---|
mock |
the binary in this repo | none — this is the demo agent |
claude-acp |
npx @agentclientprotocol/claude-agent-acp |
none, if the claude CLI is already signed in |
kilo |
kilo acp |
whatever kilo auth needs |
gemini, codex-acp, +30 more |
npx … / uvx … from the registry |
their own credentials |
Agents published only as a binary download are listed but not installed for you:
fetch one and point an [[agents]] entry at it.
Everything in mjx.toml has a working default; the file is optional and a
partial file is fine. Paths resolve relative to the file, not the working
directory.
[server]
bind = "127.0.0.1:4321" # loopback by design — see SECURITY.md
[workspace]
roots = ["demo/workspace"] # the fs jail, and the cwd choices in the picker
[registry]
url = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"
cache_dir = ".mjx-cache" # the catalog still works offline from this
[[agents]] # shadows the registry entry with the same id
id = "kilo"
name = "Kilo"
command = "kilo"
args = ["acp"]
[[mcp_servers]] # offered to every agent this server starts
name = "git"
command = "npx" # a bare name resolves on PATH; "./x" against this file
args = ["-y", "@modelcontextprotocol/server-git"]
env_from = { GITHUB_TOKEN = "GITHUB_MCP_TOKEN" } # read from the environmentConfigured agents lead the picker, in the order you wrote them.
The browser is the ACP client, so mcpServers is nominally its to fill in — but
the servers are configured here, their paths resolve against this file, and their
credentials must not travel to a browser to be sent straight back. So the server
adds them to session/new, session/load, session/fork and session/resume
on the way past, merging by name so a client that configures its own keeps it.
transport is one of:
transport |
Who connects to it | Fields |
|---|---|---|
stdio (default) |
the agent | command, args, env, env_from |
http, sse |
the agent | url, headers, headers_from |
acp |
this server | command, args, env, env_from |
Only stdio is mandatory for an agent. An http, sse or acp server is
dropped for an agent that did not declare that transport in mcpCapabilities,
with the reason shown in the sidebar — an agent silently discards mcpServers
entries it does not understand, so nothing else would say so.
acp is MCP-over-ACP: this server spawns the MCP server and holds it, and the
agent reaches it through mcp/connect and mcp/message. It is offered as a name
and nothing else, so the command, its environment and therefore its credentials
never reach the agent — and an agent that could not spawn it (sandboxed, or
elsewhere) can still use it. Stdio commands only; the streamable-HTTP MCP
transport is not implemented here.
env_from and headers_from name an environment variable rather than holding a
token, so a working configuration can be committed. A variable that is not set
leaves that server configured but unoffered, with the reason in the sidebar.
cargo test --workspace # 338 tests
cargo clippy --workspace --all-targets
npm --prefix web test # 191 tests
npm --prefix web run typecheck
npm --prefix web run dev # hot reload against a `cargo run` serverThree test tiers, described in CONTRIBUTING.md:
- Unit — frame parsing, request↔method correlation, the thread fold, the line diff, the filesystem jail.
- Fixture parity — both thread models folded through the same recorded turn.
- End-to-end —
crates/mjx-acp-server/tests/relay.rsstarts the real server on a real port, connects a real WebSocket, and drives the real mock agent through a real PTY.
Against a running server, over the browser's own code path:
node web/scripts/smoke.mjs # drive a full turn and check every surface
node web/scripts/capture-fixture.mjs # re-record fixtures/session-updates.jsonlThe browser is not optional as a test surface. Node's ws is lenient where a
browser is strict, and real bugs have only ever shown up in Chromium.
| Path | What |
|---|---|
crates/mjx-acp-core |
JSON-RPC frames, ACP v1 method names, request↔method correlation, the _mjx/* vocabulary |
crates/mjx-acp-thread |
The thread model — a GPUI-free port of Zed's acp_thread |
crates/mjx-agent-catalog |
ACP registry fetch and agent command resolution |
crates/mjx-workspace |
Filesystem jail and PTY terminal manager |
crates/mjx-mcp |
An MCP server this process holds open, for MCP-over-ACP |
crates/mjx-acp-server |
The relay: static assets, /api/*, /ws, and the pool of running agents |
crates/mjx-mock-agent |
Scripted credential-free agent, for the demo and the tests |
web/ |
The browser client; web/src/acp/ is protocol-only and React-free |
demo/pristine/ |
The demo's source project, copied to the ignored demo/workspace/ |
fixtures/ |
The recorded turn, the mention URIs, the elicitation shapes, and a registry snapshot |
reference/ |
Where the local-only Zed copy goes. Git-ignored. |
- A reload keeps the conversation, but not the terminal scrollback. The agent outlives the socket, so refreshing rejoins the same agent and the same session, and a turn that was running carries on. What a terminal printed before the reload is gone, though: that lives in the workspace rather than in the thread the server folds.
- A dropped connection is not reconnected for you. A new socket can rejoin a running agent, but nothing retries automatically after a network blip — reload the page.
- Terminals are display-only. ACP gives a client no way to type into a terminal the agent started, so neither does this.
- Binary-only registry agents are not installed for you. Roughly fifteen
publish no
npx/uvxdistribution; they are listed with an explanation. - A form's half-filled answer does not survive a reload. The question and what was finally answered do — they are part of the thread — but text typed into a form and not yet sent is browser-local and goes with the page.
- Session history is only as good as the agent's. Everything in the drawer
comes from
session/list; an agent that does not keep conversations has none, and the drawer is not offered at all.additionalDirectorieson a load or a fork is not sent, and pagination is a "load more" rather than infinite scroll. - Only three kinds of mention can be made.
@offers a file, a directory or a URL — everything the browser has a source for. The other tenMentionUrivariants (a symbol, a selection, a diagnostic set…) parse and render, because an agent may send one, but nothing here makes one: that needs an editor. - A mention chip is not clickable. Clicking it should open the file it names, and there is no editor here to open it in.
- A mention is always a
resource_link, never embedded content, even when the agent advertisesembeddedContext. The browser cannot read a file to embed it; the server could, but that would be another interception and it needs its own reason written down. - The composer shows the raw
[@name](uri)while you type. The text is the model, the way Zed's is; the sent message shows a chip. - The file picker is not
.gitignore-aware and is capped at 500 entries. A.envunder a workspace root is offered by name. See SECURITY.md. - MCP-over-ACP hosts stdio servers only. An
acp-transport entry runs a command; the streamable-HTTP MCP transport is a subsystem of its own. - MCP servers cannot be chosen per session from the UI. The list is per
connection, from
mjx.toml, which is read at startup — changing it needs a restart. mcp/*is forwarded when nothing is configured to be hosted here. An agent that reaches for MCP-over-ACP uninvited is talking to a browser that does not implement it, and getsmethod not found. That is the truth: pretending otherwise would leave the agent's request parked forever on the re-ask list.- No authentication. See below.
There is no authentication, deliberately, so the demo works with no setup.
The consequence is blunt: anyone who can reach the port can read your files and
run commands as you. The server binds 127.0.0.1 only and refuses a non-loopback
address without an explicit flag; the filesystem is jailed to the configured
roots. Terminals are not sandboxed. Read SECURITY.md before
exposing it to anything.
GPL-3.0-or-later. This project ports code from Zed, which is GPL-3.0-or-later, so the copyleft carries over. Every ported piece names its origin in a doc comment; see NOTICE for the full attribution.
The Agent Client Protocol SDKs it depends on are Apache-2.0.