---
title: "Define a routine"
description: "Bind a scheduled or event-driven job to an identity, tools and limits."
canonical_url: "https://docs.oz0.ai/build/parts/routines"
---
# Define a routine

> Bind a scheduled or event-driven job to an identity, tools and limits.

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: now` applies one during the fire. A routine can also call declared write tools when it has no shell access ([Tools and safe outputs](https://docs.oz0.ai/build/parts/routines/outputs-and-permissions)).

A routine can also run a plugin's evals on a schedule instead of an agent: an [eval routine](https://docs.oz0.ai/improve/evals/regressions#scheduled-evals) (`eval: {plugin: ...}`), which the edge fires itself, with required budgets, and whose runs are compared case by case to find a regression.

```yaml [triage.yaml]
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
```

```bash [Terminal]
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](https://docs.oz0.ai/concepts/limits-and-scaling).

### Schedules

Each schedule trigger is a [Temporal Schedule](https://docs.temporal.io/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.

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Meaning
    </th>
    
    <th>
      Default
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        cron
      </code>
      
       or <code>
        every
      </code>
    </td>
    
    <td>
      A five-field cron expression, or an interval such as <code>
        15m
      </code>
      
       or <code>
        24h
      </code>
      
       (at least <code>
        1m
      </code>
      
      )
    </td>
    
    <td>
      one of them is required
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        timezone
      </code>
    </td>
    
    <td>
      An IANA zone for <code>
        cron
      </code>
      
      , such as <code>
        Europe/Stockholm
      </code>
    </td>
    
    <td>
      UTC
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        jitter
      </code>
    </td>
    
    <td>
      Each fire waits a random time up to this long, so many routines do not fire at once
    </td>
    
    <td>
      none
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        overlap
      </code>
    </td>
    
    <td>
      When a fire is due while the last one runs: <code>
        skip
      </code>
      
      , <code>
        buffer_one
      </code>
      
      , <code>
        buffer_all
      </code>
      
      , <code>
        cancel_other
      </code>
      
      , <code>
        terminate_other
      </code>
      
       or <code>
        allow_all
      </code>
    </td>
    
    <td>
      <code>
        skip
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        catchup_window
      </code>
    </td>
    
    <td>
      After an outage, fires missed within this window still run
    </td>
    
    <td>
      <code>
        1h
      </code>
      
      , rather than Temporal's year
    </td>
  </tr>
</tbody>
</table>

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

```bash [Terminal]
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](https://docs.oz0.ai/govern/kill-switches), 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](https://docs.oz0.ai/build/parts/flows#start-one-from-a-webhook): `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:

1. the body may be at most `max_body_kb` (1024 unless set), and must be JSON;
2. `filter`, a [CEL](https://docs.oz0.ai/build/parts/flows/steps-and-expressions#expressions) expression over `trigger.body` and `trigger.headers` of at most 1,024 bytes, must be true;
3. with `sender` and `allow_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](https://docs.oz0.ai/govern/kill-switches) 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](https://docs.oz0.ai/build/parts/event-sources), which a plugin declares, such as GitHub's pull requests:

```yaml
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](https://docs.oz0.ai/build/parts/event-sources/delivery-binding#bind-events-to-the-delivery), 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](https://docs.oz0.ai/agents/waiting-for-events), such as CI on the pull request, when the routine lists `oz.wait_for_event` in its tools.

### Run now and dry runs

```bash [Terminal]
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

<table>
<thead>
  <tr>
    <th>
      What
    </th>
    
    <th>
      Default
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Tools
    </td>
    
    <td>
      none unless listed
    </td>
  </tr>
  
  <tr>
    <td>
      Network
    </td>
    
    <td>
      none unless listed
    </td>
  </tr>
  
  <tr>
    <td>
      Outputs
    </td>
    
    <td>
      wait for a person unless <code>
        approval: false
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Budget per fire
    </td>
    
    <td>
      $1 (<code>
        limits.budget_usd
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      Budget per routine per UTC day
    </td>
    
    <td>
      $10 (<code>
        limits.daily_budget_usd
      </code>
      
      ; <code>
        0
      </code>
      
       for no cap)
    </td>
  </tr>
  
  <tr>
    <td>
      Webhook, event and manual fires
    </td>
    
    <td>
      60 per hour (<code>
        limits.max_fires_per_hour
      </code>
      
      ), however many arrive at once: each takes one of the hour's places before it starts
    </td>
  </tr>
  
  <tr>
    <td>
      Repeated failures
    </td>
    
    <td>
      paused after 3 failed fires in a row (<code>
        limits.pause_after_failures
      </code>
      
      ; <code>
        -1
      </code>
      
       for never), with the reason shown
    </td>
  </tr>
  
  <tr>
    <td>
      Stop date
    </td>
    
    <td>
      90 days after the last save or resume, so saving or resuming renews it (<code>
        limits.stop_after: YYYY-MM-DD
      </code>
      
       or <code>
        never
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      Missed schedule fires
    </td>
    
    <td>
      made up within 1 hour
    </td>
  </tr>
  
  <tr>
    <td>
      Time per fire
    </td>
    
    <td>
      the agent's own limits only (<code>
        limits.timeout
      </code>
      
      , such as <code>
        30m
      </code>
      
      , ends a fire that runs longer)
    </td>
  </tr>
</tbody>
</table>

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](https://docs.oz0.ai/build/plugins/verified/support-triage) ships one per ticket system; the [Plugin improver](https://docs.oz0.ai/build/plugins/verified/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.

<note>

Not yet: routines a plugin installs itself (its templates are files an admin saves), a preflight step before a webhook fire, and safe outputs from harness agents. A webhook routine reaches a harness agent with a shell or write tools only when a node of the tenant with [sandbox tier 1](https://docs.oz0.ai/concepts/security/sandbox-tiers#sandboxes) can run that agent: saving it is refused otherwise, as `plugin install` refuses such a webhook flow.

</note>

## Related tasks

- [Routine permissions and outputs](https://docs.oz0.ai/build/parts/routines/outputs-and-permissions): Review what a run may read and the changes it may queue.

<span id="in-the-web-ui">

Section on this page

</span>



<legacy-sections>

- <span id="outputs-applied-during-a-fire">

Section

</span>

: [outputs applied during a fire](https://docs.oz0.ai/build/parts/routines/outputs-and-permissions#outputs-applied-during-a-fire)
- <span id="safe-outputs">

Section

</span>

: [safe outputs](https://docs.oz0.ai/build/parts/routines/outputs-and-permissions#safe-outputs)
- <span id="what-a-fire-may-do">

Section

</span>

: [what a fire may do](https://docs.oz0.ai/build/parts/routines/outputs-and-permissions#what-a-fire-may-do)

</legacy-sections>


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
