Skip to content

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.

providerHeader readSigned base stringReplay protection
githubX-Hub-Signature-256: sha256=<hex>raw bodydedupe on X-GitHub-Delivery
stripeStripe-Signature: t=<ts>,v1=<hex><ts>.<raw body>±5 min timestamp window
slackX-Slack-Signature: v0=<hex>v0:<ts>:<raw body>±5 min timestamp window
standardStandard Webhooks webhook-signature: v1,<b64><id>.<ts>.<raw body>window + dedupe on webhook-id
customwhatever your verify: function readswhatever it computesdedupe 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:

ProviderWho generates the secret
GitHubYou. openssl rand -hex 32, then paste it into the repo’s webhook settings.
Standard WebhooksYou, and you configure your own sender with it.
StripeStripe. Copy the signing secret from Developers → Webhooks → [endpoint].
SlackSlack. Copy the Signing Secret from the app’s Basic Information page.
customAlmost 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:

VariableWhat
eventthe 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
bodythe parsed JSON payload
headersevery request header, lower-cased
outputthe 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"' # stripe
match: 'event == "app_mention"' # slack

Binding 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:

  1. Every matching entry fires, not the first. A merged PR that two agents care about is two threads, not a precedence fight.
  2. The flat form is exactly a one-entry messages:. It desugars at config load, so nothing downstream knows which spelling you used.
  3. Flat keys and messages: together is a load-time error, not a precedence rule.
  4. messages: works on a schedule trigger too — one cron pinging several agents. Only match: 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

FieldApplies toNotes
schedulescheduleCron expression, 5 or 6 fields. Mutually exclusive with webhook.
webhook.providerwebhookgithub · stripe · slack · standard · custom.
webhook.secretwebhookRequired. Normally a ${VAR} reference.
webhook.verifywebhookRequired for — and only valid on — provider: custom. Path resolved against the zooid.yaml directory.
asbothFull MXID (@cron:example.org), or a bare localpart expanded against the workforce’s sole Matrix transport.
roombothRoom alias (#ops:example.org) or room id (!abc:example.org). Must start with # or !.
mentionbothAn agent key from agents:, never text.
textbothThe message body. Literal on a schedule; ${...}-interpolated on a webhook.
matchwebhookCEL predicate. Absent means always fire.
messagesbothList 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 derived user_id, an explicit as: is never auto-prefixed with the workstation — write as: my-workstation.cron if 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-reconciliation

Webhook 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 inbound
listener — 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" — skipping

Exposing 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

SymptomCause
401 on every deliverySecret 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 postsNo 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 silentThe 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 emptyThe CEL expression didn’t resolve. Unresolvable placeholders render as empty by design; check the field path against a real payload.
Webhook never arrivesmode: client binds no listener, or the proxy isn’t forwarding /_zooid/.
Daemon won’t startA 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.