Client tools¶
Client tools let a connected app (the Leo Mac/iOS app, any API client) offer tools to your agents. The daemon only relays: an agent calls a tool, the daemon parks the call and publishes it on the event stream, the app that registered the tool performs it and posts the result back. The daemon knows nothing about what a tool does; names, schemas and results belong to the app.
Wire fixtures for every request and response live in internal/clienttools/testdata/wire/.
For apps¶
All routes are under /api/v1/client-tools (operator or paired-device token) and on the daemon's unix socket. Responses use the usual {ok, data, error, code} envelope.
- Connect. Open
GET /api/v1/events?client_tools=1(operator token only; an agent token gets 403). Thehelloframe carries yourclient_id. Closing the stream withdraws everything you registered. - Register.
POST /api/v1/client-toolswith{client_id, group, tools: [{name, description, input_schema, timeout_s}]}. A group is replaced as a whole.timeout_s: 0means the call never times out (a handoff). Errors:invalid_tool,too_many_tools(64 per group and per daemon),unknown_client(404),name_conflict(409). Tool names cannot collide with Leo's own tools or another client's.input_schemais forwarded to every agent's harness, so it is validated in full: an object schema using onlytype(a primitive type or a list of them),properties,required,items,additionalProperties(bool or schema),anyOf/oneOf/allOf,enum,const,default,examples,description,title,format,pattern,minimum/maximum(andexclusive*,multipleOf),min/maxLength,min/maxItems,min/maxProperties,uniqueItems,deprecated/readOnly/writeOnly,$schema,$comment; every nested schema is checked, to 16 levels and 512 schemas. Unknown keywords ($ref,patternProperties, tupleitems, ...) and wrong value types areinvalid_tool, with the JSON path in the message (at $.properties.ref.type: unknown type "bogus"). - Serve. On a provider stream you receive
client_tool.requestevents with the agent, tool and arguments. Claim it withPOST /requests/{id}/claim {client_id}, then answer withPOST /requests/{id}/result {client_id, ok, content | error}.contentis an array of MCP content blocks (text, image). On (re)connect,GET /requests?pending=1lists what is still waiting. A call whose agent stops or whose tool is withdrawn arrives asclient_tool.cancelled. - Ownership. The first claimant of a request owns that agent's group from then on (
409 owned_by_otherfor a different client,already_claimedfor a second claim). A second result is409 already_settled; a result from a non-claimant is409 not_claimant; an unknown or settled id is404 unknown_request. A claim or result without aclient_idthat names a connected client is404 unknown_client.
Other event streams never see call arguments: client_tool.request and client_tool.cancelled arrive redacted to ids, so seq stays contiguous. client_tools.changed (revision and tool names) goes to everyone. The hello feature list includes client_tools.
For agents¶
Each agent's MCP server shows the tools according to the harness:
- Claude Code and opencode re-list tools on
notifications/tools/list_changed, so every registered client tool appears as a native tool and the server notifies (coalesced to one second) when the set changes. - codex and unknown harnesses do not re-list in a session, so they get two fixed tools:
leo_app_tools(list what apps offer) andleo_app_call {tool, args}. They are listed only if an app has ever registered with this daemon, sampled once when the session first lists tools; an app registering for the first time needs an agent restart to be seen.
Results pass through verbatim, images included; failures come back as tool errors "<code>: <message>" (no_client, timeout, cancelled, or the app's own). deny_tools can name a client tool or either fallback tool; the names of tools apps have ever registered are remembered in ~/.leo/state/client-tools.json so a deny_tools entry stays valid at boot before the app reconnects. Which harness honors list_changed was measured by scripts/harness-probe (internal/mcp/harnesscaps.go).