Define a routine
For: plugin authors.
Define a routine to run an agent or flow from a Temporal Schedule, a signed webhook or a manual start. Its definition binds the target to its triggers, allowed tools, queued safe outputs and limits. Each run follows these rules:
- runs as the routine's own service identity,
service:<name>, never as the person who wrote it; - calls only the tools the routine lists;
- gets the trigger's payload as labelled data after its task, never mixed into its instructions;
- can queue changes as safe outputs: the edge checks them, waits for a person where the routine says so, and applies them with credentials only the edge holds. Outputs run after the fire by default;
apply: nowapplies one during the fire. A routine can also call declared write tools when it has no shell access (Tools and safe outputs).
A routine can also run a plugin's evals on a schedule instead of an agent: an eval routine (eval: {plugin: ...}), which the edge fires itself, with required budgets, and whose runs are compared case by case to find a regression.
name: triage
description: Labels new issues
agent: acme-triager # or flow: <name>
input: Label the issue in the trigger data.
owner_team: support
run_as: triage-bot # fires run as service:triage-bot; the routine's name when left out
triggers:
- name: nightly
schedule: { cron: "0 3 * * *", timezone: Europe/Stockholm, jitter: 5m }
input: Look at yesterday's unlabelled issues.
- name: github
webhook:
secret: GITHUB_WEBHOOK
filter: trigger.body.action == 'opened'
sender: trigger.body.sender.login
allow_senders: [kevin, ada]
tools: [acme_github.read_issue] # none unless listed
network: [api.github.com] # what the node's egress proxy may let through; none unless listed
outputs:
- name: add_labels
description: Add labels to the issue
schema:
type: object
properties: { labels: { type: array, items: { type: string } } }
required: [labels]
allow: { labels: [bug, question, feature] }
http: { url: "https://hooks.example.com/labels", secret: LABELS_HOOK }
limits:
budget_usd: 0.50 # per fire
daily_budget_usd: 5
stop_after: 2026-12-31
oz0 routine check -f triage.yaml # what is wrong, without saving
oz0 routine save -f triage.yaml # version 1; the edge fires it within seconds
oz0 routine list
routine check and routine save say what the tenant lacks for the routine. Whether a secret it names is set, and how that secret is bound, they say only to someone who may read the tenant's secrets (secrets.read); to anyone else they say what each secret must be, the same whether it is set or not, and the edge checks it at every fire and post.
Every save is an immutable version with a hash; saving the same definition again makes no new version. A fire records the routine version it ran; like any job, it stays on the runtime version it started on, and on one plugin version for its whole run, also during a plugin rollout. routine rollback <name> <version> saves an older version's definition as the next one. Tenant admins save and delete routines; operators run, pause and resume them; readers see them.
Triggers
A routine has at most 64 triggers and 64 outputs. See limits.
Schedules
Each schedule trigger is a Temporal Schedule that the edge creates, changes and removes with the routine. The schedule lives in Temporal, so a routine keeps firing while management is down.
| Field | Meaning | Default |
|---|---|---|
cron or every | A five-field cron expression, or an interval such as 15m or 24h (at least 1m) | one of them is required |
timezone | An IANA zone for cron, such as Europe/Stockholm | UTC |
jitter | Each fire waits a random time up to this long, so many routines do not fire at once | none |
overlap | When a fire is due while the last one runs: skip, buffer_one, buffer_all, cancel_other, terminate_other or allow_all | skip |
catchup_window | After an outage, fires missed within this window still run | 1h, rather than Temporal's year |
oz0 routine fires <name> (which takes jobs.read, since fires are jobs) shows each schedule's next fire, how often it has fired and whether it is paused, and why. To run the fires of a past period, for example after a pause:
oz0 routine backfill triage --from 6h --overlap buffer_all
A backfill keeps to the routine's limits. Temporal runs backfills even while a schedule is paused, so the edge checks eligibility first. It refuses a routine that is paused or stopped, has no installed agent, is stopped by a kill switch, has a retired agent, or belongs to a tenant whose region move is draining from this edge's region.
One backfill starts at most limits.max_fires_per_hour fires. Their possible spend, at up to budget_usd each, must fit in daily_budget_usd together with today's spending and what running fires may still spend. Because backfill fires are all due at once, allow_all is refused.
Webhooks
A webhook trigger fires on POST /hooks/<tenant>/<routine> on the edge's HTTPS port, or /hooks/<tenant>/<routine>/<trigger> when the routine has several. The caller proves it knows the tenant secret named in secret, as for flows' webhooks: Authorization: Bearer <secret>, or an HMAC-SHA256 signature of the body as X-Hub-Signature-256 (GitHub's header) or X-Oz0-Signature. Then, before anything costs money:
- the body may be at most
max_body_kb(1024 unless set), and must be JSON; filter, a CEL expression overtrigger.bodyandtrigger.headersof at most 1,024 bytes, must be true;- with
senderandallow_senders, the sender the expression names must be in the list.
A delivery that the filter or the allowlist rules out answers 200 with {"fired": "false", "reason": "..."}. A fire answers 202 with its job ID. What the sender signed names the job, rt.<routine>.<trigger>.<delivery>: with a signature, the SHA-256 of what it covers, so the same signed body sent again never runs twice, whatever its delivery headers say; with a bearer secret, which is the secret itself, the delivery header (Idempotency-Key, X-GitHub-Delivery or X-Request-Id).
A sender that sends the same body again on purpose signs a timestamp with it: X-Oz0-Timestamp: <Unix seconds> and X-Oz0-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<body>">; a timestamp more than five minutes from the edge's clock is refused (401). A paused routine, or one past its daily budget or its fires per hour, answers 429. While a kill switch stops its agent or flow, a delivery gets 503 with the switch's reason (oz0_killed), as a flow's webhook does.
A routine whose webhook secret is not set yet refuses every delivery with the 401 a wrong signature gets (the edge's log says which it was), so the answer tells nobody which secrets the tenant has. The secret is a signing key bound by name to the edge's address senders post to, with its port (oz0 secret set GITHUB_WEBHOOK --signing --host oz0-edge.acme.example:7443): the edge API's advertised address, its --public-edge-url or a --san name at the edge API's port, which the operator sets, never the host a request names.
Any other secret, a credential such as a database's password above all, a key that signs what goes to another host or one bound by a wildcard, checks nothing and gets the same 401, so whoever may write a routine cannot name another secret as its webhook's and test guesses of it.
Events
An event trigger fires on the events of an event source, which a plugin declares, such as GitHub's pull requests:
triggers:
- name: pr-opened
event:
source: github-events
types: [pull_request]
filter: event.action == 'opened'
sender: event.attributes.sender
allow_senders: [kevin, ada]
filter and sender are CEL over event (its type, action, subject, attributes and text), and read only what the edge verified of it against the delivery: the type, action, subject and attributes its source binds, and the text. A trigger that reads a field its source does not bind does not fire, so allow_senders holds only over a sender the source binds.
A fire's job is rt.<routine>.<trigger>.<event id>, so a delivery sent again fires once, and event fires count toward limits.max_fires_per_hour as webhook fires do. The fire's agent may wait for further events, such as CI on the pull request, when the routine lists oz.wait_for_event in its tools.
Run now and dry runs
oz0 routine run triage --payload '{"action": "opened", "issue": 7}' --wait
oz0 routine run triage --dry-run --payload '{"action": "opened", "issue": 8}' --wait
A fire someone starts runs as the routine's identity too; the log and the audit record who asked. A dry run takes the same path as any fire, and its outputs are checked and recorded as dry_run, never applied.
Safe defaults
| What | Default |
|---|---|
| Tools | none unless listed |
| Network | none unless listed |
| Outputs | wait for a person unless approval: false |
| Budget per fire | $1 (limits.budget_usd) |
| Budget per routine per UTC day | $10 (limits.daily_budget_usd; 0 for no cap) |
| Webhook, event and manual fires | 60 per hour (limits.max_fires_per_hour), however many arrive at once: each takes one of the hour's places before it starts |
| Repeated failures | paused after 3 failed fires in a row (limits.pause_after_failures; -1 for never), with the reason shown |
| Stop date | 90 days after the last save or resume, so saving or resuming renews it (limits.stop_after: YYYY-MM-DD or never) |
| Missed schedule fires | made up within 1 hour |
| Time per fire | the agent's own limits only (limits.timeout, such as 30m, ends a fire that runs longer) |
A spent daily budget pauses the routine's schedules until midnight UTC; its webhooks answer 429 until then. A routine that pauses itself shows who paused it (system) and why; routine resume starts it again, and only failures after that count towards the next pause.
In the console
Routines, under Build, lists each routine with what it runs, its triggers and outputs, its team, what it spent today and whether it is paused. A routine's page has:
- Definition: the YAML in a text editor that the server checks as you type, against the tenant's agents, tools and secrets; save makes the next version.
- Test run: a payload and a dry run, with the outputs the fire would make and the answer.
- Fires: its schedules with their next fire, and every fire with its trigger, version and status.
- Outputs and Versions, and Run now, Pause and Delete.
A fire's job page has an Outputs tab, and its approval appears with the others.
From your own app
The runtime API has FireRoutine, ListRoutineFires and BackfillRoutine; a job has routine, routine_version, trigger, dry_run and outputs. The admin API has ListRoutines, GetRoutine, CheckRoutine, SaveRoutine, DeleteRoutine and SetRoutinePaused. In Temporal, every fire carries the search attributes Oz0Routine, Oz0RoutineVersion and Oz0Trigger.
Routines a plugin ships
No manifest key installs a routine: a routine runs as its own identity, owned by one of the tenant's teams, with the tools and limits an admin chose, so an admin saves it. A plugin that has routines to offer ships them as files in a routines/ folder, which its docs page lists and an admin saves with oz0 routine save -f <file>, after setting its owner_team and, for a schedule, its input.
Support triage ships one per ticket system; the Plugin improver ships one each for GitHub's CI events, the platform's results events, a weekly schedule and a closed pull request. Each lists only the read-only functions its flow calls, and the last one the two platform built-ins its flow calls without a model (oz.report_outcome, oz.propose_update): none reaches a coding session, so its fires never hold a shell and a write. A routine lists a built-in its flow's steps call as it lists a function (oz.propose_update); a fire calls only what it lists.
plugin install refuses such a webhook flow.Related tasks
- Routine permissions and outputs: Review what a run may read and the changes it may queue.
Section on this page
Previous section links
- Section: outputs applied during a fire
- Section: safe outputs
- Section: what a fire may do