External agent messaging (opencode in Docker)¶
Status: proposed · Date: 2026-08-13
Goal¶
Two-way messaging between a Leo agent and an opencode agent running in Docker that Leo does not supervise, with the container bounded to messaging exactly one Leo agent.
Replies must land in the session that sent the message, not "whatever session is newest" — the container may have a human switching between sessions.
Non-goals¶
- Fleet visibility for the container: no
leo agent list/attach/logs. It is not a supervised agent and will not pretend to be one. - Per-agent tokens for Leo's own agents. Existing
api.token/agent.tokensemantics are untouched (see Compatibility). leo mcp-serverin the container. opencode's MCP client passes no session identity (tools/call={name, arguments}, verified), so it cannot address replies.
Scope¶
Only one of the three pieces below ships in Leo: api_clients. The container-side plugin and the reply skill are an example integration kept outside this repo — they track opencode's unversioned API, which is not something Leo should carry. They are documented here because they are what proved the design, and because the pattern generalizes to any external agent.
Design¶
Three pieces, each independently shippable.
1. Container: an opencode plugin (one tool) — not shipped in Leo¶
A plugin defines a single tool, e.g. message_leo(text). Plugin tools receive ToolContext = {sessionID, messageID, agent, directory, worktree, ...}, so the handler knows its own session id per call — the only non-heuristic route.
The handler POSTs the raw text to Leo (the daemon stamps the sender identity — prefixing on both sides would double it):
POST http://<leo-host>:8370/web/agent/<target>/message
Authorization: Bearer <client token>
{"text": "...", "from": "docker-scout#ses_abc123"}
The tool surface is one tool by construction — nothing else to deny.
2. Leo agent: a skill (reply path) — not shipped in Leo¶
The target agent's template gets a skill: parse the #ses_… reply address out of the incoming message, then
Verified working end to end against opencode 1.17.7 — the message appears in the live session and is answered. Use the non-/api namespace: POST /api/session/{id}/prompt returns 200 with an admittedSeq and then silently never executes.
Cold start (Leo speaks first, no captured address): the container creates a pinned "channel" session at boot via POST /session and exposes its id; the skill uses that as the default target.
3. Leo: scoped API clients (the boundary) — the part that ships¶
Without this the plugin's token is fleet-wide: any agent, plus all of /api/*.
api_clients: # NOT `client:` — that is the existing remote-host section
docker-scout:
can_message: [rocket]
- Token generated by
leo client add <name>, stored0600under~/.leo/state/clients/<name>.token, printed once. - Default-deny. A request bearing a client token is rejected everywhere except
POST /web/agent/{name}/messagewherenamematchescan_message. Matching is on the literal argument with glob support, consistent with template permissions. frombecomes trustworthy. The server requiresfromto be<client-name>or<client-name>#<suffix>and rejects anything else, so the reply address cannot be spoofed into another client's identity. Todayfromis self-asserted and display-only.
Compatibility¶
api.token and agent.token keep their current unscoped meaning; only tokens under api_clients carry a scope. No existing caller changes behavior. The leo CLI is unaffected — it authenticates over the Unix socket, never a bearer token. Neither token file is rotated or re-keyed (live agents hold agent.token in their process env).
Acceptance criteria¶
- Client token → allowed target:
200, message delivered. - Client token → any other agent:
403. - Client token →
/api/agent/spawn(and every other/api/*route):403. fromnot matching the authenticating client:400.api.token/agent.tokenregression tests: unchanged on every route.- End-to-end: container plugin → Leo agent →
prompt_async→ reply lands in the originating session while a different session is selected in the container.
Order of work¶
- Plugin + skill against the existing agent token — proves the round trip.
- Scoped client tokens.
- Swap the plugin's token.
A later round found the plugin requires an api_clients token rather than merely preferring one: the daemon stamps the sender identity (and with it the reply address) only for scoped clients, so an unscoped token delivers a message the recipient cannot answer.
Risks¶
- The container must be reachable from Leo (inbound port) for the reply path. Fine on one host; a NAT'd container would need Leo to subscribe to the container's
/eventstream instead, which is a larger design. - The plugin is a TypeScript component living outside the Leo repo — chosen deliberately over vendoring it, so Leo carries no knowledge of opencode's API. The cost is that the two can drift; the mitigation is that Leo's side of the contract is just "POST a message with a
from", which is stable. - opencode API surface is unversioned and has a live/vestigial route split; the skill and plugin should pin the routes verified here.