Skip to content

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 | podman
workstation: 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:8448

mode: — 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 either port (default 9099, when the daemon and homeserver share a host or network) or advertise_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_url don’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 network
transports:
matrix:
homeserver: http://localhost:8448
mode: appservice
port: 9099
# pull, remote: laptop daemon against a homeserver elsewhere
transports:
matrix:
homeserver: https://your-homeserver.example
mode: client

Full field list: MatrixTransportConfig.

HTTP transport

A plain HTTP API transport for webhook-driven setups:

transports:
my-http:
type: http
port: 3000

Full 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-server

Full 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-macbook
agents:
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.reviewer

Display 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, gemini
acp:
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 forever

A 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.

PresetSpawned asDefault imageWhere the model is chosen
claudenpx -y @agentclientprotocol/claude-agent-acpghcr.io/zooid-ai/agent-claude-codemodel: → --model
codexnpx -y @zed-industries/codex-acp -c web_search="live"ghcr.io/zooid-ai/agent-codexmodel: → -c model="…"
opencodeopencode acpghcr.io/zooid-ai/agent-opencodeopencode.json — model: is ignored
pinpx -y pi-acpghcr.io/zooid-ai/agent-pi.pi-agent/settings.json — model: is ignored
clinecline --acp—not wired
kirokiro --acp—not wired
geminigemini --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 / ~/.codex exists (it warns rather than fails if not, since the credential may live in the macOS Keychain). API key writes ANTHROPIC_API_KEY / OPENAI_API_KEY to .env.
  • opencode — has no subscription/key split; auth is a provider choice. The wizard defaults to the opencode-go provider and asks only for the key, writing agents/<name>/opencode.json with an {env:OPENCODE_API_KEY} reference plus the matching .env line. --provider reaches the others (opencode, anthropic, openrouter, or custom for 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 the defaultProvider / defaultModel you already run from ~/.pi/agent/settings.json, falling back to a pinned defaultProvider: 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 to zooid.yaml by 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: true

Full shape: AcpMount.