zooid.yaml
The zooid.yaml file is the declarative manifest for your Zooid daemon. It defines the runtime that wraps each agent’s ACP shim, the transports that connect the workforce to the outside world, and the agents themselves.
This page orients you with annotated examples. For the authoritative field-by-field reference, see the generated API pages — they are emitted directly from the TypeScript types the daemon parses, so they cannot drift from the implementation.
Shape at a glance
runtime: podman # local | docker | podmanworkstation: my-macbook # optional; names this daemon and scopes its agents
container: # optional workforce-wide container defaults (no env) image: ghcr.io/zooid-ai/agent-opencode:latest
transports: # at least one entry, keyed by an operator-chosen name matrix: homeserver: http://localhost:8448
agents: # at least one entry, keyed by the agent's name assistant: acp: preset: opencode matrix: rooms: - '#general'
triggers: # optional; wake an agent on a cron or a webhook nightly: schedule: '0 6 * * *' as: '@cron' room: '#general' mention: assistant text: 'Morning sweep — anything waiting on us?'See ZooidConfig for every top-level field.
Workstation
The optional top-level workstation: names this daemon — a short, lowercase, hyphen-separated slug (my-macbook, ec2-prod). Setting it turns the daemon into a named workstation: the Matrix transport derives its sender_localpart and an exclusive @{workstation}.* user namespace from the slug, so several daemons can share one homeserver without their agent IDs colliding.
workstation: my-macbook# agents become @my-macbook.assistant:your-server, etc.zooid init scaffolds workstation: dev, so agents start out namespaced (@dev.*). The field is optional — omit it for the shared @.* namespace — but each daemon sharing a homeserver needs its own slug.
Transports
The transports: block lets the daemon expose multiple endpoints or connect to multiple servers at once. Each entry is discriminated by type.
Matrix transport
The Matrix transport runs as an Application Service and impersonates each agent’s user via ?user_id=. Most fields (type, sender_localpart, user_namespace, as_token, hs_token) default sensibly:
transports: matrix: homeserver: http://localhost:8448mode: — push or pull
mode: chooses which side opens the connection (see Workstations for the concept):
appservice(default) — push. The homeserver delivers events to the daemon, so the daemon needs an address the homeserver can reach. Set eitherport(default9099, when the daemon and homeserver share a host or network) oradvertise_url(a routable address, when the daemon lives on a separate machine, pod, or cluster). The two are mutually exclusive.client— pull. The daemon fetches events itself, so it needs no inbound address —port/advertise_urldon’t apply. This is the mode for a laptop or any daemon behind a firewall.
# push, co-located: homeserver reaches the daemon on a shared networktransports: matrix: homeserver: http://localhost:8448 mode: appservice port: 9099
# pull, remote: laptop daemon against a homeserver elsewheretransports: matrix: homeserver: https://your-homeserver.example mode: clientFull field list: MatrixTransportConfig.
HTTP transport
A plain HTTP API transport for webhook-driven setups:
transports: my-http: type: http port: 3000Full field list: HttpTransportConfig.
Agents
Each entry under agents: declares one agent. transport, matrix_user_id, and workdir default from the agent key when omitted:
agents: assistant: acp: preset: opencode matrix: user_id: '@assistant' # shorthand for @assistant:your-server display_name: 'Code Assistant' # optional: human-readable name rooms: - '#general' # shorthand for #general:your-serverFull field list: AgentConfig.
Identifiers and Implicit Servers
Inside zooid.yaml, you can omit the homeserver suffix from user IDs and room aliases. The daemon automatically infers the correct server from your transport’s user_namespace. For example, @docs resolves to @docs:localhost (or whatever your configured server is). This keeps the YAML terse and reduces errors.
When you set a workstation:, an agent with no explicit user_id is automatically scoped to it — architect becomes @my-macbook.architect, landing inside the exclusive namespace. Set user_id only to override the derived name:
workstation: my-macbookagents: architect: # → @my-macbook.architect:your-server acp: { preset: claude } reviewer: acp: { preset: claude } matrix: user_id: '@my-macbook.qa' # override the auto-derived @my-macbook.reviewerDisplay Names
You can provide an optional display_name field for each agent under the matrix: block. This sets the human-readable name written to the agent’s Matrix profile, causing the agent to render nicely in the Zooid web client (“Code Assistant” instead of @assistant). If omitted, it falls back to the user ID’s localpart.
The acp: block
acp: is an XOR — either a registered preset or an explicit command:
acp: preset: claude # claude, codex, opencode, pi, cline, kiro, geminiacp: command: node args: ['./my-custom-acp-shim.js']Full shape: AcpAgentSpec. What each preset actually runs is below.
Idle sessions: session_idle_timeout
Every thread an agent works in keeps a live session. A daemon that runs for weeks would otherwise hold one for every conversation it ever had. Instead, a thread that has gone quiet is put to sleep after session_idle_timeout (default 10m), and its resources are released. Nothing is lost: the next message in that thread picks the conversation up where it left off, with its full context.
agents: architect: acp: { preset: claude } session_idle_timeout: 30m # "30s", "15m", "2h" — or 0 to keep sessions foreverA thread is never put to sleep mid-turn or while it’s waiting on you for an approval. How much memory this frees depends on the agent: with claude each sleeping thread gives back a whole agent process, while opencode keeps one process for all its threads. If an agent can’t reopen a session later, the daemon keeps its sessions alive rather than risk losing context, and says so once in its log.
Hooks
The daemon accepts hooks: at the workforce and agent levels but does not execute them. If any are configured, it warns at startup and lists the affected agents and hook names. Configure hooks through the agent’s own mechanism, such as Claude Code’s settings.json hooks or opencode plugins.
Presets
A preset is a short name for the command that starts a harness in ACP mode, plus the default container image and the host state that harness expects. The registry lives in @zooid/acp-client; preset: <name> is the only thing that needs to appear in your zooid.yaml.
| Preset | Spawned as | Default image | Where the model is chosen |
|---|---|---|---|
claude | npx -y @agentclientprotocol/claude-agent-acp | ghcr.io/zooid-ai/agent-claude-code | model: → --model |
codex | npx -y @zed-industries/codex-acp -c web_search="live" | ghcr.io/zooid-ai/agent-codex | model: → -c model="…" |
opencode | opencode acp | ghcr.io/zooid-ai/agent-opencode | opencode.json — model: is ignored |
pi | npx -y pi-acp | ghcr.io/zooid-ai/agent-pi | .pi-agent/settings.json — model: is ignored |
cline | cline --acp | — | not wired |
kiro | kiro --acp | — | not wired |
gemini | gemini --acp | — | not wired |
Two things follow from the table. First, model: only reaches harnesses that publish a model flag; opencode and pi route through their own config files instead, and setting model: on them is silently inert. Second, the four presets with a first-party image are the four zooid init offers and the four that carry host state into a container — cline, kiro, and gemini run fine, but you supply the image (container.image) and any state mounts yourself.
For the host directories each preset bind-mounts under runtime: docker / podman, see Run in a container.
How zooid init authenticates each one
zooid init asks a different question per preset, because the harnesses do not share an auth model. Every preset can run off a subscription you already have instead of a fresh API key; what differs is how that is arranged.
claude/codex— asks subscription or API key. Subscription reuses the login already on the host and only checks that~/.claude/~/.codexexists (it warns rather than fails if not, since the credential may live in the macOS Keychain). API key writesANTHROPIC_API_KEY/OPENAI_API_KEYto.env.opencode— has no subscription/key split; auth is a provider choice. The wizard defaults to theopencode-goprovider and asks only for the key, writingagents/<name>/opencode.jsonwith an{env:OPENCODE_API_KEY}reference plus the matching.envline.--providerreaches the others (opencode,anthropic,openrouter, orcustomfor a hand-written config).pi— has both: a provider table and an OAuth subscription tier. It only offers the subscription choice once it has actually found a login at~/.pi/agent/auth.json; with no login the question is skipped and it goes straight to an API key. Choosing to share it symlinks that file into the agent’s config dir, so token refreshes on your side stay visible to the agent. It also inherits thedefaultProvider/defaultModelyou already run from~/.pi/agent/settings.json, falling back to a pinneddefaultProvider: openrouter/defaultModel: deepseek/deepseek-v4-pro— pi’s own default resolves to a model that returns an empty turn.cline/kiro/gemini— not offered by the wizard. Add the agent tozooid.yamlby hand and log the CLI in yourself.
Only pi gets a relocated config directory. PI_CODING_AGENT_DIR=.pi-agent in the generated .env moves pi’s global settings into agents/<name>/.pi-agent/, so the agent’s model pin and session state stay in the project instead of editing the ~/.pi you use interactively. The path is relative on purpose: the agent’s working directory is agents/<name> locally and /workspace in a container, and those are the same directory. Note the interaction with containers — a shared pi login is a symlink into your host $HOME, which a container cannot follow; see the caution in Run in a container.
Triggers
The optional top-level triggers: block wakes an agent without a human typing a mention. Each entry fires on either a cron schedule: or a webhook: delivery, then posts a message into a room as a bot user (as:), structurally mentioning an agent (mention:) — so the turn starts through the ordinary message path and lands in its own thread.
triggers: agent-image-currency: # a cron: state that only reveals itself when you look schedule: '0 6 * * 1' as: '@cron:example.org' room: '#ops:example.org' mention: architect text: 'Check whether the pinned agent CLI versions are behind npm latest.'
github: # a webhook: events that announce themselves webhook: provider: github # github | stripe | slack | standard | custom secret: ${GITHUB_WEBHOOK_SECRET} as: '@hook:example.org' messages: # one endpoint, several agents - room: '#product:example.org' mention: product match: 'event == "issues" && body.action == "opened"' text: 'Triage ${body.repository.full_name}#${body.issue.number}.'A webhook trigger is served at POST /_zooid/webhooks/<trigger-name> on the daemon’s HTTP listener, with an HMAC signature check over the raw body. mention: names an agent key and is never templated; match: is a CEL predicate and applies only to webhook triggers.
What a trigger is and when to use one: Triggers. Providers, secrets, match expressions, templating, and exposing the route: Setting up triggers. Field reference: TriggerConfig.
Environment variables
Zooid expands ${VAR} references in your YAML against the daemon’s process.env (and an adjacent .env). For security, env vars are never forwarded into agent containers automatically — list them explicitly under container.env:
agents: assistant: container: image: 'ghcr.io/zooid-ai/agent-claude-code:latest' env: ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}ZOOID_* keys and ${ZOOID_*} references are rejected. See ContainerConfig for the full field list.
Mounts
The agent’s workdir is auto-mounted into its container. To expose additional host paths (e.g. a source repo the agent needs to read), declare them under container.mounts:
agents: assistant: container: mounts: - source: /host/path/to/repo target: /workspace/repo readonly: trueFull shape: AcpMount.