Triggers & scheduling
Let an org react to the clock and the outside world — schedules, scheduled scripts, inbound webhooks, process watchers, and polled inboxes that seed goals.
Last updated
Without triggers an org is sealed: it advances its mission on a fixed tick
and on directives you type into the dashboard. A ## Triggers section in your
QUORUM.md opens it up — so it reacts to the clock and to inbound events, in
service of that same mission. A fired trigger seeds a goal, exactly like an
operator message, and flows through the normal cycle.
One ### heading per trigger (the heading is its name); the prose is the
directive it seeds when it fires. The kind is inferred from the fields you
give it. A section missing either — no heading, or no prose and no
directive: key — is skipped quietly, so if a trigger never appears on the
dashboard’s Triggers panel, check it has both.
## Triggers
### Weekly review
- every: 7d
Review progress against the mission and decide the single most important thing
to ship next week.
The four kinds
Schedule — react to the clock
A schedule trigger declares exactly one of:
| field | fires |
|---|---|
every |
on a fixed recurrence since it last fired — 45s, 30m, 6h, 7d, 2w. Never fired yet? It starts one interval after it was created — a roadmap-review shouldn’t run against an org that has done nothing yet. Polls are the exception and start right away: a run: job and an inbox both cost nothing when there’s nothing there |
cron |
when local wall-clock time matches a standard 5-field expression min hour dom mon dow — supports *, a number, ranges a-b, lists a,b, and /n steps |
at |
once, at or after a single ISO datetime — a one-shot future commitment |
Downtime is handled per kind: an at moment the org slept through still fires
on the next boot (“at or after”), and an overdue every fires as soon as the
org is back. cron matches only the live wall-clock minute — minutes the org
slept through are skipped, never replayed.
### Monday planning
- cron: 0 9 * * 1
Kick off the week: re-plan the top goal and rebalance work across the team.
### Attend the 3pm call
- at: 2026-07-06T15:00:00
- owner: Account Lead
Join the scheduled customer call and capture the decisions and follow-ups.
The one-shot at kind is an appointment — a time-bound commitment the org
keeps punctually. That’s any one-shot work, not just meetings: join the 3pm
call, verify the release at 10pm, act on a deadline.
- The owner attends. When the moment arrives and
ownernames an employed agent (by role or id), the work lands on the board directly assigned to them and runs immediately — no planning ceremony, no queue. Task review and budgets apply as for any task, and work that turns out bigger than one agent delegates or escalates like any other. (For a mechanical check that needs no judgment, aRUN[at …]job is cheaper — no agent at all unless it prints output.) - No owner? It still jumps the queue. The fired goal takes priority over
the normal goal rotation and is planned the cycle it fires; an
owner:naming a role the org lacks auto-hires it through the plan. - On time, not tick-aligned. Every future appointment arms a timer that wakes the org at the exact moment: an idle org attends to the second, no matter the tick interval; a busy org attends as soon as in-flight work settles (running sessions are never preempted).
Webhook — react to an inbound event
A trigger with no schedule or command (or an explicit kind: webhook) is fired
by an external request to POST /api/trigger/<name>:
### Inbound signup
- kind: webhook
A new signup arrived. Qualify the lead, add it to the CRM, and line up a
conversation to validate pricing.
curl -X POST http://your-host:3000/api/trigger/inbound-signup \
-H "X-Quorum-Secret: $QUORUM_TRIGGER_SECRET" \
-H "content-type: application/json" \
-d '{"text": "acme.co — 40-seat team, from the pricing page"}'
The endpoint is fail-closed. It does nothing unless you set a shared secret
in the QUORUM_TRIGGER_SECRET environment variable, and every call must present
it — as an X-Quorum-Secret header or Authorization: Bearer <secret> (compared
in constant time). It’s also only reachable when you bind the dashboard beyond
loopback (--host 0.0.0.0); by default it listens on 127.0.0.1 only.
Crucially, the caller can’t inject instructions. Only the trigger’s own
configured directive is seeded; an optional {"text": "..."} body is attached as
context. So even an exposed endpoint can’t steer the org off its mission.
Process watcher — react to a job finishing
A trigger with a command runs that shell command and watches it. When the
process exits, the trigger fires — carrying the exit code and a tail of its
output as context, so the org can act on the result.
### Deploy finished
- command: ./scripts/wait-for-deploy.sh
Review the deployment that just completed and log any regressions found.
Watches are re-established on every start, so a restart re-arms them.
Inbox — hold a conversation with the outside world
A trigger with a url (or an explicit kind: inbox) is conversational
inbound: the runtime polls the endpoint on the trigger’s cadence (every,
default 5m) and seeds genuinely new mail as a goal, so the reply obligation
flows through the normal cycle — the org hears back from the people it emails.
### Support inbox
- url: https://mail.example.dev/messages
- secret: mail-inbox-token
- allow: ["*@example.dev", "vip@partner.io"]
Read each new message and reply on the "email" channel; file real prospects
with CONTACT.
Your machine is never exposed: mail lands somewhere the org can poll —
typically a Cloudflare Email Worker storing messages at the edge, which the
org’s own build agent can deploy — and the runtime performs each poll,
presenting the secret as a bearer token that no agent ever holds. The
endpoint returns a JSON array of messages (or {"messages": [...]}), each with
from, text, and optionally id, subject, and at.
Because inbound mail is a conversation, it’s fenced fail-closed on both axes:
- Senders. Only mail matching the
allowlist — exact emails,*@domainwildcards, or an explicit*— is ingested; everything else is dropped and audited. An inbox with no allowlist ingests nothing. - Content. The message text is attached to the goal as quoted data for the trigger’s directive. It can inform the work; it is never treated as instructions or as operator input — the same discipline as a webhook payload.
Messages are deduped by id across restarts, a burst is capped per firing (the rest ride the next poll), and when a sender matches a contact in the CRM, the goal context says so — the conversation threads to the customer record.
Run one inbox per function. support@ and sales@ are just two trigger
sections — each with its own endpoint (or per-box path on one Email Worker),
allowlist, cadence, and directive — and owner: pins where each one’s work
lands:
### Support inbox
- url: https://mail.example.dev/messages/support
- secret: mail-inbox-token
- allow: ["*"]
- owner: Support Lead
Read each message, fix or answer the problem, and reply on the "email" channel.
### Sales inbox
- url: https://mail.example.dev/messages/sales
- every: 15m
- secret: mail-inbox-token
- allow: ["*"]
- owner: Head of Sales
Qualify each sender, CONTACT them into the CRM, and draft a tailored reply.
A message’s to address (when the endpoint supplies one) rides into the goal
context, so even a single shared endpoint can say which address the mail
arrived at.
Scheduled job — poll the outside world cheaply
A schedule trigger with a run script runs that script headlessly on its
schedule — no agent, no token cost. A silent run (clean exit, no output) just
advances the schedule; the org only wakes, seeding the directive as a goal with
the script’s output as context, when the script prints something or fails.
### Poll the inbox
- every: 5m
- run: ./drain-inbox.sh
New replies arrived — read each and continue the conversation.
It’s how an org polls cheaply — drain an inbox, poll a queue, ping a health
check — and pays for an agent turn only when there’s genuinely work to do. A job
is killed if it runs past two minutes, so a hung poll can’t wedge a cycle. Jobs
run from the jobs/ folder (below), so run: ./drain.sh
finds the script next to its manifest.
Agents schedule themselves
Triggers aren’t only author-declared. While working a task, an agent can book a future goal itself by adding a line to its output:
SCHEDULE[2h]: Attend the customer call and capture the decisions
<when> may be a delay (2h, 30m, 3d), an absolute time
(at 2026-07-06T15:00), or a recurrence (every 7d). Bookings are bounded —
a few per task — and each is written as a
persisted schedule trigger, so it fires at its moment even across restarts
— and it’s owned by the booking agent: the rep that arranges a call is the
one activated to attend it when the time comes. This is how the org commits to
real, time-bound obligations on its own, rather
than assuming the same process is still running. It’s the same output-marker pattern
as HIRE[...] and DELEGATE[...], and it’s gated by the schedule
capability (deny it to lock self-scheduling down).
An agent can likewise schedule a headless job with RUN[<when>]: <shell command> — the same script-on-a-schedule that only wakes an agent when it prints
output (e.g. RUN[every 5m]: ./drain-inbox.sh to poll for replies without
spending a turn each time). Because it defers shell execution, it’s gated by the
code capability, not schedule.
The jobs/ folder
Jobs live in a jobs/ folder at the org root — the agent-owned counterpart
to QUORUM.md. Each job is a manifest jobs/<name>.json beside its script:
{ "every": "5m", "run": "./drain-inbox.sh", "directive": "New replies arrived — read each and reply." }
RUN[...] writes one of these for you, but the point is that the org manages
its own cron: an agent with code can create, edit, disable ("enabled": false), or delete jobs by writing and removing files in this folder — it’s the
one shared directory in an agent’s sandbox — and the runtime picks the changes up
live, no restart. Jobs run with jobs/ as their working directory. Operator
jobs can still be declared in ## Triggers with a run: key; both sources run
side by side.
Keys
| key | meaning |
|---|---|
every / cron / at |
schedule (pick one — see above) |
command |
process: the shell command to run and watch |
run |
schedule: a script run headlessly on the schedule (no agent, no cost) from the jobs/ folder; wakes an agent only when it prints output |
url |
inbox: the HTTPS endpoint the runtime polls (GET, JSON) for new messages |
secret |
inbox: name of the org secret presented as a bearer token on each poll — read at poll time, never held by an agent |
allow |
inbox: sender allowlist — exact emails, *@domain, or *. Required: no list, no ingest |
owner |
who the fired work lands with. On a one-shot at, the attendee — the task is assigned to them directly and runs immediately. On other kinds, the plan assigns the work there (a missing role auto-hires) |
kind |
force schedule, webhook, process, or inbox (usually inferred) |
enabled |
false keeps a trigger defined but dormant (default true) |
directive |
the seeded directive, as a key instead of the prose body |
name |
the trigger’s name (default: the heading); a webhook fires at POST /api/trigger/<name> |
Lifecycle
Triggers are persisted with their firing history, so editing ## Triggers and
restarting takes effect immediately without re-firing what already ran.
Deleting a section removes its trigger; triggers agents booked at runtime with
SCHEDULE[...] — and the jobs/ folder — are managed separately, so an edit
to ## Triggers never disturbs them. Each
firing is recorded in the audit log and shown on the dashboard’s
Triggers panel — name, kind, schedule, and how many times it has fired.