Skip to content

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.token semantics are untouched (see Compatibility).
  • leo mcp-server in 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

POST http://<container>:4096/session/<ses_id>/prompt_async
{"parts":[{"type":"text","text":"..."}]}

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>, stored 0600 under ~/.leo/state/clients/<name>.token, printed once.
  • Default-deny. A request bearing a client token is rejected everywhere except POST /web/agent/{name}/message where name matches can_message. Matching is on the literal argument with glob support, consistent with template permissions.
  • from becomes trustworthy. The server requires from to be <client-name> or <client-name>#<suffix> and rejects anything else, so the reply address cannot be spoofed into another client's identity. Today from is 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

  1. Client token → allowed target: 200, message delivered.
  2. Client token → any other agent: 403.
  3. Client token → /api/agent/spawn (and every other /api/* route): 403.
  4. from not matching the authenticating client: 400.
  5. api.token / agent.token regression tests: unchanged on every route.
  6. 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

  1. Plugin + skill against the existing agent token — proves the round trip.
  2. Scoped client tokens.
  3. 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 /event stream 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.