Zed, Toad, Devin Desktop and OpenHands can all drive any agent that speaks
the Agent Client Protocol (agentclientprotocol.com) —
JSON-RPC 2.0 over stdio. Lectern needed a custom runner per CLI before this;
now any ACP agent gets a live timeline, mid-run steering and gated approvals
— the same capabilities codex-appserver already gives Codex — with no
backend code per agent. internal/drivers/acp.go implements the client
side; internal/agents.ACPDefinition / internal/sessions.ACPSpec are the
acp: {command, args, env} registry field (see
docs/agents.md).
Settings → Agents → Add agent → the "ACP agent" starter template, or the
terminal dashboard's Q shortcut, offer three presets — each is only a
starting point; they are not pre-registered, and Lectern never assumes the
binary exists until you save and dispatch against it:
| Preset | Command | Notes |
|---|---|---|
| Claude Code (ACP) | npx -y @agentclientprotocol/claude-agent-acp |
The ACP adapter (renamed from Zed's deprecated @zed-industries/claude-code-acp); needs a Claude Code login exactly like the built-in claude agent |
| Codex (ACP) | npx -y @agentclientprotocol/codex-acp |
The ACP adapter (replaces Zed's deprecated @zed-industries/codex-acp); ships a platform-specific native binary via optionalDependencies |
| Gemini CLI (ACP) | gemini --experimental-acp |
Gemini CLI's own native ACP mode — no adapter package, but gemini itself must already be installed |
Or by hand:
curl -X PUT .../api/agents -d '[{
"name": "claude-code-acp",
"command": "claude-code-acp",
"acp": {"command": "npx", "args": ["-y", "@agentclientprotocol/claude-agent-acp"]}
}]'A task dispatched on this agent picks the acp driver automatically —
visible as attempt.driver == "acp" on the task view, exactly like
attempt.driver == "codex-appserver" for a gated Codex run. Interactive
sessions are unaffected: they stay tmux-based, same as every other agent —
ACP is for headless work (tasks, routines, best-of-N, evals), where a
protocol built for a client to observe and drive a turn structurally is a
better fit than screen-scraping a TUI.
Read directly off @agentclientprotocol/sdk@0.14.1's schema/schema.json
and dist/{acp,stream}.js (the exact dependency
@zed-industries/claude-code-acp@0.16.2 ships with, via npm pack, never
executed against a real model) — not the docs site's prose, which proved
lossy when cross-checked against the shipped SDK (its own summariser invented
methods that do not exist, such as session/list and $/cancel_request).
- Framing: newline-delimited JSON-RPC 2.0 over stdio — one
JSON.stringify(message) + "\n"per message, not Content-Length/LSP framing.protocolVersionis an int;PROTOCOL_VERSIONis1. - Agent methods (Lectern → agent):
initialize,session/new,session/prompt; notificationsession/cancel. - Client methods (agent → Lectern):
fs/read_text_file,fs/write_text_file,session/request_permission; notificationsession/update. session/prompt's response is the turn's own completion ({stopReason, usage?}) — unlike Codex'sturn/start, which acks immediately and reports completion via a separate notification. This driver issues it from a dedicated background goroutine rather than blockingStart/Sendon it, since a real turn can run far longer than any other request this codebase makes.terminal/*(terminal/create/output/wait_for_exit/kill/release) exists in the schema but this driver declines it outright (clientCapabilities.terminal: falseininitialize) — see Limits.
| ACP | Lectern event |
|---|---|
session/update → agent_message_chunk / agent_thought_chunk |
text |
session/update → tool_call (first sight of a toolCallId) |
tool_use (name from ToolKind: execute→Bash, edit/delete/move→Edit, read/search→Read, fetch→Fetch, else Tool) |
session/update → tool_call/tool_call_update with status: completed|failed |
tool_result (is_error on failed; content is the tool's rendered text/diff-path/[terminal output] parts, or its title if none) |
session/update → plan |
text, rendered as a - [ ]/- [x] checklist |
session/prompt's {stopReason, usage?} response |
result (subtype: "success" for end_turn or an operator-requested cancelled; "error" otherwise — max_tokens, max_turn_requests, refusal, an unrequested cancelled) |
session/update → available_commands_update, current_mode_update, config_option_update, session_info_update, usage_update |
not surfaced (accepted and ignored) |
session/request_permission is routed through internal/broker exactly like
Codex's execCommandApproval/applyPatchApproval — the tool name/input
shape it produces is the same one the approvals UI already renders for
claude/codex, so it needed no ACP-specific case there.
| Lectern permission mode | Behaviour |
|---|---|
default (gated) |
Every session/request_permission call creates a real pending approval and waits for a decision, exactly like a Codex gated task |
acceptEdits, plan, bypassPermissions, steerable, unset |
Auto-allow: the protocol's allow_always option is selected (falling back to allow_once if the agent did not offer one) |
The protocol requires the client to answer every still-pending permission
request with {outcome:{outcome:"cancelled"}} once it has sent
session/cancel — this driver's Cancel sets that flag first, so any
request arriving after cancellation is answered that way instead of being
routed anywhere.
session/prompt is request/response — its response is the turn ending,
so there is no way to fold a message into an already-in-flight call the way
Codex's turn/steer or Claude's streaming-input fifo can. Send while a
turn is active queues the message; the driver's own loop starts it as a
fresh session/prompt on the same session the moment the current one's
response arrives — "a follow-up session/prompt after the current turn
ends, or queue," exactly as specified.
The client capability this driver advertises (fs.readTextFile,
fs.writeTextFile) is scoped to the attempt's own worktree, not the whole
target: fs/read_text_file/fs/write_text_file calls are resolved with
filepath.Rel against the worktree root, and anything that is not an
absolute path inside it — a relative path, a .. escape, an absolute path
elsewhere — is refused with a JSON-RPC error before executor.Executor ever
sees it. line/limit (1-based line window) are supported for a partial
read.
terminal/*is declined, not implemented. A well-behaved ACP agent falls back to its own tool-call flow (asking for permission to run a command, same as it would for any client with no terminal capability) — which this driver already handles — so nothing is lost for a correctly-written agent; a non-compliant one calling it anyway gets a clean protocol error rather than a hang.- No
session/load/ resume. A follow-up attempt starts a fresh ACP process and session, the same as every other driver's follow-up today — there is no cross-process continuation of an ACP session. - No
session/set_mode/session/set_model. Lectern's ownmodelfield is not translated onto an ACP agent. planupdate field names are a best-effort read, not independently re-verified against the SDK schema during this work — a shape mismatch silently emits no plan text rather than erroring, so this is safe to be wrong about, just possibly quiet.usagefield names onsession/prompt's response are similarly best-effort (input_tokens/output_tokens); an agent that reports usage under different keys just shows no token count, not an error.- Interactive sessions do not use ACP. They stay tmux-based; ACP is
wired for headless tasks/routines/best-of-N/evals only (see GAP context in
docs/agent-events.md§4 for the same split oncodex-appserver).