Setting up triggers
This guide is the configuration reference for triggers. For what a trigger is and when to use one, read Triggers first.
Two kinds
triggers: <name>: schedule: "0 6 * * 1" # cron — OR — webhook: { ... } # HTTP ingress as: "@cron:example.org" # the bot identity that posts room: "#marketing:example.org" mention: social text: | What's trending on twitter?Exactly one of schedule: or webhook: is required; both together is a load-time error. Which one you want follows from whether the thing you care about announces itself.
Schedule triggers
Nothing calls you when a package publishes to npm, so the only way to learn a pinned version is behind latest is to ask:
triggers: agent-image-currency: schedule: "0 6 * * 1" # Mondays at 06:00 as: "@cron:zooid.example.org" room: "#ops:zooid.example.org" mention: architect text: | Check whether the pinned agent CLI versions are behind npm `latest` (`pnpm -C zooid agent-images:check`). If any are, open a bump PR. If none are, say so and stop.schedule: takes a cron expression parsed by croner — five fields (min hour dom mon dow), or six with a leading seconds field. It is validated at config load, so a malformed expression fails zooid start with croner’s own error rather than silently never firing.
Write the text: so a no-op is an acceptable answer — “If none are, say so and stop” above is doing that work, since a scheduled agent wakes on every firing whether or not there’s anything to report.
text: is literal for a scheduled trigger. There is no payload, so there is nothing to interpolate, and match: is a load-time error here for the same reason.
Webhook triggers
Each webhook trigger gets one route on the daemon’s HTTP listener:
POST /_zooid/webhooks/<trigger-name>A merged pull request announces itself, so asking GitHub once a day what changed would be a poll for something it already tried to tell you:
triggers: knowledge-reconciliation: webhook: provider: github secret: ${GITHUB_WEBHOOK_SECRET} as: "@hook:zooid.example.org" room: "#product:zooid.example.org" mention: product match: 'event == "pull_request" && body.action == "closed" && body.pull_request.merged' text: | ${body.repository.full_name}#${body.number} merged. Check whether a shipped spec's description of behavior stopped being true, and open a PR if so. "Nothing to update" is a valid answer.Providers and signatures
Every delivery is authenticated by an HMAC over the raw request body, using that trigger’s own secret. A bad signature, a missing signature, or an unknown trigger name all return the same 401 — so the endpoint can’t be probed to discover which triggers exist.
provider | Header read | Signed base string | Replay protection |
|---|---|---|---|
github | X-Hub-Signature-256: sha256=<hex> | raw body | dedupe on X-GitHub-Delivery |
stripe | Stripe-Signature: t=<ts>,v1=<hex> | <ts>.<raw body> | ±5 min timestamp window |
slack | X-Slack-Signature: v0=<hex> | v0:<ts>:<raw body> | ±5 min timestamp window |
standard | Standard Webhooks webhook-signature: v1,<b64> | <id>.<ts>.<raw body> | window + dedupe on webhook-id |
custom | whatever your verify: function reads | whatever it computes | dedupe on the id it reports |
Use standard for senders you control (n8n, your own scripts) rather than inventing a scheme.
Secrets
The secret is a ${VAR} reference resolved against the daemon’s environment and adjacent .env, exactly like container env. One secret per trigger, so rotation is scoped and compromising one integration authorizes nothing else.
Which side generates it depends on the provider:
| Provider | Who generates the secret |
|---|---|
| GitHub | You. openssl rand -hex 32, then paste it into the repo’s webhook settings. |
| Standard Webhooks | You, and you configure your own sender with it. |
| Stripe | Stripe. Copy the signing secret from Developers → Webhooks → [endpoint]. |
| Slack | Slack. Copy the Signing Secret from the app’s Basic Information page. |
custom | Almost always the service — check its webhook settings page. |
Which deliveries fire: match:
A match: is a CEL expression evaluated against the delivery. It must return true for the message to post; a delivery that matches nothing is still acknowledged (202) and simply posts nothing.
Four variables are bound:
| Variable | What |
|---|---|
event | the provider’s event name, read from wherever that provider puts it — the X-GitHub-Event header for GitHub, body.type for Stripe and Standard Webhooks, body.event.type for Slack. Unset for provider: custom, where only you know the payload shape |
body | the parsed JSON payload |
headers | every request header, lower-cased |
output | the whole payload, pretty-printed as JSON text |
match: 'event == "issues" && body.action == "opened"'match: 'event == "pull_request" && body.action == "closed" && body.pull_request.merged'match: 'event == "issues" && body.issue.labels.exists(l, l.name == "bug")'match: 'event == "invoice.payment_failed"' # stripematch: 'event == "app_mention"' # slackBinding event per provider is the point: match: reads the same whichever service is calling, and where the name lives — a header on GitHub, a payload field everywhere else — stays a detail Zooid handles.
match: fails closed. Only a literal true fires — a truthy string, a number, a missing field, or a typo that errors all count as “no match”. A merged-PR filter legitimately errors on every issues delivery, and that is exactly the behaviour you want.
CEL is used rather than jq or a mini-DSL because it is non-Turing-complete by design: a filter can never hang the ingress. It is the same choice Kubernetes made for ValidatingAdmissionPolicy, for the same reason.
Expressions are parse-checked at config load, so a syntax error fails daemon start rather than the first delivery.
Filling in the message: ${...}
text: interpolates ${...} over the same four bindings — one expression language, not two:
text: 'Triage ${body.repository.full_name}#${body.issue.number}.'Prefer a reference over the payload. Pasting a raw payload puts attacker-written prose — an issue title and body from any public repo — straight into the agent’s prompt. Sending two structured fields and letting the agent fetch the issue through its own tooling keeps that text out of the room entirely. ${output} dumps the whole payload and is there for senders you control.
Two safety properties are worth knowing:
- An unresolvable placeholder renders as empty, rather than throwing or pasting an error object into the room. A bad placeholder never takes the message down.
- The rendered result is never re-scanned, so a payload containing the literal text
${...}cannot inject a placeholder of its own.
The rendered body is capped (payload text truncates at 60,000 characters with a … (truncated) marker) so a message always fits inside a Matrix event.
Several messages from one endpoint
The flat room:/mention:/text:/match: above is the base case. A trigger that serves one endpoint but wakes different agents pluralizes it:
triggers: github: webhook: provider: github secret: ${GITHUB_WEBHOOK_SECRET} as: "@hook:zooid.example.org" messages: - room: "#product:zooid.example.org" mention: product match: 'event == "issues" && body.action == "opened"' text: 'Triage ${body.repository.full_name}#${body.issue.number}.'
- room: "#dev:zooid.example.org" mention: architect match: 'event == "pull_request" && body.action == "closed" && body.pull_request.merged' text: 'PR ${body.number} merged — check whether a shipped spec went stale.'Rules:
- Every matching entry fires, not the first. A merged PR that two agents care about is two threads, not a precedence fight.
- The flat form is exactly a one-entry
messages:. It desugars at config load, so nothing downstream knows which spelling you used. - Flat keys and
messages:together is a load-time error, not a precedence rule. messages:works on a schedule trigger too — one cron pinging several agents. Onlymatch:is webhook-only.
provider: custom
For any scheme without a named provider — ed25519, SHA-1 over sorted params, a bespoke timestamped base string — point verify: at a module that does the check:
triggers: vendor-events: webhook: provider: custom secret: ${VENDOR_WEBHOOK_SECRET} verify: ./verifiers/vendor.mjs # resolved against the zooid.yaml directory as: "@hook:zooid.example.org" room: "#ops:zooid.example.org" mention: ops text: 'Vendor event ${body.kind} received.'The module exports the verifier as default (a named verify export also works, so one module can hold several):
import { createHmac, timingSafeEqual } from 'node:crypto'
export default function verify({ rawBody, headers, secret }) { const sig = headers['x-vendor-signature'] if (!sig) return false const expected = createHmac('sha1', secret).update(rawBody).digest('hex') const a = Buffer.from(sig), b = Buffer.from(expected) if (a.length !== b.length || !timingSafeEqual(a, b)) return false
// Returning the delivery id opts this trigger into replay dedupe — // only your verifier knows where the service puts one. return { ok: true, deliveryId: headers['x-vendor-delivery'] }}headers carries every header, lower-cased (named providers only get a fixed set). Return true, or { ok, deliveryId? }. Throwing counts as rejection — nothing a verifier does can turn into an accept, and a custom trigger whose module failed to load rejects every delivery.
Modules are imported once at daemon start, so a bad path or a wrong export fails zooid start rather than surfacing as a mysterious 401 the first time the service fires.
Fields
| Field | Applies to | Notes |
|---|---|---|
schedule | schedule | Cron expression, 5 or 6 fields. Mutually exclusive with webhook. |
webhook.provider | webhook | github · stripe · slack · standard · custom. |
webhook.secret | webhook | Required. Normally a ${VAR} reference. |
webhook.verify | webhook | Required for — and only valid on — provider: custom. Path resolved against the zooid.yaml directory. |
as | both | Full MXID (@cron:example.org), or a bare localpart expanded against the workforce’s sole Matrix transport. |
room | both | Room alias (#ops:example.org) or room id (!abc:example.org). Must start with # or !. |
mention | both | An agent key from agents:, never text. |
text | both | The message body. Literal on a schedule; ${...}-interpolated on a webhook. |
match | webhook | CEL predicate. Absent means always fire. |
messages | both | List of room/mention/text/match entries. Mutually exclusive with the flat keys. |
See TriggerConfig, TriggerMessage, and WebhookTriggerConfig for the generated field reference.
as: — the identity that posts
Every trigger posts as a Matrix user in the daemon’s namespace, registered on demand through the Application Service. Give it a name that reads well in scrollback: @cron, @hook, @github.
Two rules the config enforces:
- A bare localpart (
cron,@cron) is expanded to@cron:<server>using the workforce’s single Matrix transport. With more than one Matrix transport, write the full MXID. Unlike an agent’s deriveduser_id, an explicitas:is never auto-prefixed with the workstation — writeas: my-workstation.cronif you want that. as:must not equal the mentioned agent’s own MXID. A message an agent sends never routes back to itself, so such a trigger would silently never fire. This is rejected at load time.
mention: is structural, never text
mention: names an agent key, and the daemon sets m.mentions.user_ids on the event directly. Writing text: "@architect do the thing" would not route — Zooid reads structured mentions first and only falls back to scanning the body when nothing else matched, and that fallback wants a full MXID.
The structured mention also disarms that fallback (it only fires when nothing matched), which is what makes interpolated payload text safe: no ${...} expansion can conjure a mention nobody wrote.
The mentioned agent must exist in agents:, must have a matrix: binding, and — for the message to arrive — must be a member of the room.
Operating
Rooms are joined at daemon start. Every room named by every trigger is joined by its bot user during bootstrap, never lazily on first delivery. A lazy join would pay a register/invite/join round trip inside the provider’s timeout, and if any step 403’d the delivery would be lost — the provider already has its 202, so nothing retries. You’ll see this in the startup log:
[trigger] joined rooms for 3 trigger(s)[trigger] scheduled 1 trigger(s)[webhook] POST /_zooid/webhooks/github[webhook] POST /_zooid/webhooks/knowledge-reconciliationWebhook triggers need push mode. The route rides the same HTTP listener as the Application Service transaction endpoint, which only binds under mode: appservice. Under mode: client (a laptop behind a firewall) the daemon binds nothing inbound, and it says so:
[webhook] 2 webhook trigger(s) configured, but pull mode binds no inboundlistener — these will never fire.Scheduled triggers work fine in either mode.
The daemon answers 202 immediately and runs the turn afterwards. GitHub times out at 10 seconds and an agent turn does not fit in that, so a delivery is acknowledged as soon as the signature verifies. Bodies over 1 MB are rejected with 413 before hashing.
One bad delivery never takes the daemon down. A failure after the response — an unresolvable room, an unknown agent, a send that errors — is logged and dropped:
[trigger:nightly] cannot resolve room #ops:example.org — skipping[webhook:github] unknown agent "produkt" — skippingExposing the route
The daemon serves POST /_zooid/webhooks/<name> over plain HTTP and verifies the signature itself. TLS termination, IP allowlisting, and rate limiting in front of it are yours — Zooid prescribes no particular proxy. With Caddy:
zooid.example.org { handle /_zooid/webhooks/* { reverse_proxy localhost:9099 } # …the rest of your site}Signature verification deliberately stays in the daemon rather than moving to the proxy, so the daemon is safe when someone fronts it with something we never anticipated — or with nothing at all.
Troubleshooting
| Symptom | Cause |
|---|---|
401 on every delivery | Secret mismatch, or a body rewritten in transit — verification is over the raw bytes, so any proxy that reformats JSON breaks it. Also the response for an unknown trigger name. |
202 but nothing posts | No match: returned true. Check the field paths against a real payload — a mistyped path counts as no match, by design. On provider: custom, event is unbound: match on body instead. |
| Message posts, agent stays silent | The mentioned agent isn’t in that room, or as: resolves to the agent’s own MXID (rejected at load — check you’re running the config you edited). |
| Placeholder renders empty | The CEL expression didn’t resolve. Unresolvable placeholders render as empty by design; check the field path against a real payload. |
| Webhook never arrives | mode: client binds no listener, or the proxy isn’t forwarding /_zooid/. |
| Daemon won’t start | A bad cron expression, an unparseable match:, or a verify: module that can’t be loaded — all validated up front, with the trigger name in the error. |