API Clients¶
An API client is an agent Leo does not supervise — typically running in a container, on another machine, or inside CI — that needs to message one Leo agent. It authenticates with a bearer token of its own, scoped to exactly that.
This is not the same thing as the client: section, which configures this machine's CLI as an SSH client of a remote leo daemon. Different direction, different job.
Why not just hand it the agent token¶
Leo already has a token for agents — agent.token, exported to every spawned agent as LEO_API_TOKEN. It is unscoped: it works on every /api/* route and every agent's message route. That is appropriate for an agent Leo started itself, running as the same user, on the same machine.
A container is a different case. Its token sits in an image layer or a compose file, somewhere Leo cannot see, and if it leaks it carries the whole fleet: spawn, stop, run tasks, message anyone. An API client token is default-deny — one route, the targets you name, nothing else.
Creating one¶
This writes the api_clients entry, generates a token at ~/.leo/state/clients/docker-scout.token (mode 0600), and prints it once.
Both add and rm require a daemon restart to take effect. That cuts both ways: leo client rm does not revoke immediately — the running daemon holds the old token in memory and keeps accepting it until you restart it.
What the token can do¶
Exactly one thing:
…where <target> matches can_message. Everything else is refused with 403 — every /api/* route, every other agent verb (interrupt, send, stop), the whole browser UI, and /login. The check runs before the request reaches the middleware the operator and agent tokens use, so there is no path by which a client token falls through to them.
can_message entries match the literal path segment, exactly or as a glob (scout-*). Unlike template permissions, an empty list denies everything and is rejected at config load. The inversion is deliberate: an empty allowlist meaning "unrestricted" is a safe default for an agent Leo spawned, and the wrong one for a token living outside Leo.
Sender identity is enforced, not asserted¶
Every request must carry a from that the client is entitled to claim:
from is either the client's own name or <name>#<session>, where the suffix carries the caller's own session id so a reply can be addressed back to it (bounded to 128 characters of [A-Za-z0-9_.-]). Anything else is a 400.
The daemon then rewrites the message body before delivery, stamping the authenticated identity on it:
This is not cosmetic. Leo types the message text verbatim into the target's pane and never renders from there, so a client that controlled the text could otherwise write its own [message from ...] line and impersonate another sender to the receiving agent. The daemon strips an embedded prefix and collapses newlines (which would submit the turn early) for the same reason.
Unlike template permissions — a guardrail enforced inside the agent's own process — this is a boundary enforced by the daemon against a party it does not trust.
Reachability¶
Two settings decide whether a client can reach the daemon at all, and both fail with 403 — the same status a scope denial returns:
web.binddefaults to127.0.0.1, so nothing outside the host can connect. A container needs an address it can route to.web.allowed_hostsmust list the host or IP the client connects to, or the Host check rejects the request before the token is looked at. TheHostheader must also carry a port matching the listener —Host: leo:8370passes where a bareHost: leodoes not, so a reverse proxy that rewrites or drops the port will be refused.
leo client add prints a reminder when the current config would not be reachable. If a client is getting 403 on a target you know is allowed, check these before suspecting the token — the response body tells the two apart:
HTTP/1.1 403 Forbidden HTTP/1.1 403 Forbidden
forbidden host {"ok":false,"error":"client \"docker-scout\" is not
permitted to message \"olympus\""}
^ Host/bind problem ^ genuine scope denial
Verified end to end¶
The boundary was exercised from a real Docker container against a live daemon (--add-host=host.docker.internal:host-gateway, web.bind: 0.0.0.0, allowed_hosts: [host.docker.internal]):
| Request from the container | Result |
|---|---|
POST /web/agent/rocket/message (allowed) | reaches the handler |
POST /web/agent/olympus/message | 403 |
POST /web/agent/rocket/interrupt | 403 |
POST /api/agent/spawn, GET /api/agent/list, GET /api/v1/state | 403 |
GET / | 403 |
from: rocket#ses_x (forged) / from absent | 400 |
Two-way messaging¶
Leo delivers into the client's message; it does not deliver to the client. Replies are the client's own problem, and the usual shape is:
- The external agent includes an address it can be reached at — its own session id, a callback URL, a queue name — in the
fromsuffix. - The Leo agent reads that address out of the delivered
[message from <client>#<address>]prefix and answers over whatever transport the client speaks.
That keeps Leo out of the business of knowing how any particular external agent receives things. If you want a Leo agent to answer automatically, give it a skill describing that transport.
A worked example — a containerized opencode agent whose plugin sends the session id as the address, with the Leo agent replying via opencode's POST /session/<id>/prompt_async — is written up in the design spec. The container-side glue is deliberately not shipped here: it tracks another tool's unversioned API, and Leo should not carry that.