Agent sessions
agentd runs coding agents as Bus sessions. Anything on the Bus can start
one: Workshop, a terminal, a Mix script, or another agent. Each running
agent's output is published as Bus events, so any number of watchers can
follow a turn. When the agent asks permission to do something, the
question goes to the human on the desk they are looking at, through
notifyd.
agentd speaks the Agent Client Protocol (ACP) to the agent. ACP is the open standard that most coding agents already support, so one integration covers them all. agentd adds what ACP lacks: addressing across the mesh, many watchers, and a human in the loop. Every session also gets mixos-mcp, so the agent can use the Bus in turn.
Bus callers ── agent.* commands, agent.session.* topics ──► agentd
│ ACP over the agent's stdio
▼
agent (claude-agent-acp, …)
│ MCP
▼
mixos-mcp ──► back onto the Bus
Run it as a session service: mix --serve agentd.mix --name agent. It
needs a mix with process_watch stdin pipes (stdin: "pipe",
process_write).
Configuration
agentd reads $MIXOS_AGENTD_CONF, else <etc>/agent.conf.mix. The file is
strict data:
max_live: 4
agents: {
claude: {argv: ["claude-agent-acp"], roots: ["/home/user/src"]},
gemini: {argv: ["gemini", "--acp"], roots: ["/home/user/src"]},
opencode: {argv: ["opencode", "acp"], roots: ["/home/user/src"], policy: "ask"}
}
-
argv: the agent's ACP command, run directly (no shell).
-
roots: the directories a session's
cwdandadditional_dirsmust lie under. Paths are resolved first, so a symlink cannot escape. This confines only what the agent asks agentd for. The agent's own tools, such as a shell, are not sandboxed by agentd. -
policy:
ask(the default) sends every permission request to the human.allow_oncelets agentd answerallow_onceby itself.
agentd never answers
allow_always. -
permission_timeout_s (default 300): with no answer by then, the request is rejected. No answer is never yes.
-
max_live (default 4): live sessions at once. It is a hard cap, and agent-started sessions count against it.
-
log_keep_days (default 30): how long the session log keeps a session after it ends.
Commands
Bodies are JSON objects.
| Command | Body | Reply |
|---|---|---|
agent.agents | the configured agents, live count and cap | |
agent.session.new | {agent, cwd, mode?, additional_dirs?} | {session, status} at once; ready follows as an event |
agent.session.prompt | {session, content} | {session, turn}; the turn streams as events |
agent.session.cancel | {session} | cancels the running turn |
agent.session.close | {session} | ends the agent |
agent.session.set | {session, mode} or {session, option, value} | sets a mode, or a config option such as the model |
agent.session.list | live and recently ended sessions | |
agent.session.read | {session, since?, limit?} | the session and its logged events after seq: {session, events, more, next} |
agent.permission.respond | {session, request, option} | answers a permission request |
content is a string, or a list of ACP content blocks.
A read returns at most limit events (default and maximum 2000) and about
32 MiB. When more is true, read again with since set to next.
The acting commands (new, prompt, cancel, close, set, respond)
take an optional id of 1-128 characters from [A-Za-z0-9._:-]. Ids are
scoped to the session (to new for agent.session.new). A retry with the
same id gets the first reply and never runs again, so a caller that lost a
reply can safely send the command again. If agentd stopped after logging a
command but before replying, the retry gets COMMAND_INTERRUPTED; the
command is not run again.
Refusals carry {error_code, message}:
| rc | error_code |
|---|---|
| 10 | USAGE, OUTSIDE_ROOTS or UNKNOWN_AGENT |
| 14 | UNKNOWN_SESSION |
| 15 | AGENT_RUNNING (a turn is already running), AGENT_NOT_READY or COMMAND_INTERRUPTED |
| 16 | AGENT_LIMIT or AGENT_DEPTH |
| 17 | AGENT_SELF |
Events
| Topic | Body |
|---|---|
agent.session.updated | {session, seq, kind, …} |
agent.session.ended | {session, exit_code, signal} |
agent.permission.requested | {session, request, title, options} |
kind on agent.session.updated is one of:
initializedorready;- an ACP update kind (
agent_message_chunk,tool_call,plan, …), with the update unchanged inupdate; stop, withstopset toend_turn,max_tokens,max_turn_requests,refusalorcancelled;permission_answered;error;ended, logged (not published) when a restart ends a session.
seq rises by one per event in a session. A watcher that missed events
reads them back with agent.session.read {since}.
Session log
agentd writes every event and every acting command to a SQLite log before
it publishes the event or runs the command: $MIXOS_AGENTD_DB, else
<var>/agentd/sessions.db. The log outlives agentd. After a restart:
- ended sessions and their events can still be listed and read;
- command receipts still answer retries;
- the agents that were running are gone (agentd owned them), so their
sessions are ended with a last
endedevent whosereasonisagentd_restart.
Sessions are dropped from the log log_keep_days after they end.
If the log cannot be written (a full disk), commands are refused, but the
agent's events are still published and the gap is reported on stderr. A
watcher that follows seq should therefore also check the session's
status in a read: after such a gap and a restart, the ended event can
carry a seq the watcher has already seen.
Agents calling back
agentd runs every agent with MIXOS_AGENT_SESSION set to its session id,
and that agent's mixos-mcp registers on the Bus as
mcp-s-<session>-<suffix>. The broker stamps that name as the sender of
everything the bridge sends, so agentd recognises a session's own agent:
- It cannot act on its own session (
AGENT_SELF): no answering its own permission requests, no prompting or closing itself. - A session it starts is a child, and a child cannot start sessions
(
AGENT_DEPTH). - The
max_livecap holds for everyone.
This catches the agent's MCP path. An agent with a shell can still open a Bus client of its own, which is why the cap and the human answer matter.