Define a routine

Routine permissions and outputs

Review what a run may read and the changes it may queue.

For: plugin authors.

Review what a run may read and the changes it may queue.

What a fire may do

  • Tools: only the functions in tools (as <tool>.<function> or <tool>.*), and that holds for the jobs it delegates to and its flow's steps. The agent does not see the others; a flow step that calls one fails. A harness agent's session asks before every tool call, and calls of tools not listed (as <harness>.<tool>, e.g. claude.Read) are refused; a harness that cannot ask before its calls does not run a routine's fire.
  • The payload is appended to the task as <trigger_data source="webhook" trigger="github">{...}</trigger_data>, with a note that it is data and not instructions. A flow gets it as trigger.body.
  • Network: network lists the hosts the fire may reach through the node's egress proxy; none unless listed.
  • Identity: fires run as service:<run_as>, owned by owner_team. The person who saved the routine is recorded on the version and nowhere else, so a routine keeps working when its author leaves, and never acts with their rights.

Owner team and quotas

owner_team must be one of the tenant's teams (oz0 team create support), and a team that owns a routine cannot be deleted until the routine names another. Every fire counts against the quotas of service:<run_as> and of the owner team. A fire that a webhook, "run now" or the test bench starts is refused over a quota (a webhook gets 429); a fire that a schedule starts asks the edge before its first step and, over a quota, fails with QuotaExceeded, which does not count toward pausing the schedule. Each refusal is in the audit trail as run.refused.

Sandbox

Every fire runs as a job started by an outside event (see Security). A session of an agent that can run a shell or write runs only on a node with sandbox tier 1 and gets no plugin secrets; where a tier-1 node of the tenant can take the fire, its tools and hooks run there too. A routine with a webhook trigger may reach such an agent only when a tier-1 node can run it, as for webhook flows.

A routine with only schedule triggers is saved either way, but when no tier-1 node of the tenant can run such an agent, saving warns that its fires would wait 10 minutes for one and then fail.

A shell or writes, never both

Each fire may either run a shell or call a tool that writes. It cannot do both in one run. Saving a routine is refused when an agent it reaches can run a shell or write files with the harness tools the routine lists (such as claude-agent-sdk.Bash), while the routine also lists a function that does not say it is read-only: one whose listing lacks readOnlyHint: true, one on a remote server, or one of a tool no node has listed yet. Its changes go through safe outputs instead (below), which the edge applies after the run.

Installing a plugin that would make a saved routine break this rule is refused too, naming the routine.

Safe outputs

Safe outputs let the edge make a change on the fire's behalf. An agent gets one extra tool, oz0.output, offered only when its routine has outputs, that queues a request such as "add these labels"; a flow step does the same with output: <name>. When the agent is done, the job hands the queue to the edge, which:

  1. checks each request against the routine's version: the output exists, the arguments match its schema, each field in allow has only allowed values, the run queued at most max of it (1 unless set), and the arguments are at most 64 KB (1 MB for all of a fire's requests together);
  2. redacts secrets, by their shape (model and cloud keys, code-host tokens, private keys, JWTs, API keys) and the exact values of every secret of the tenant that is at least 8 characters long, including those its tools' plugins run with;
  3. neutralizes @mentions outside code spans (a zero-width word joiner after the @, so the text reads the same and pings nobody) and replaces links to hosts not in allowed_domains with a note naming the host, including what renderers link without a scheme (www.host, //host, host/path, a bare host.com). It first decodes HTML character references and markdown escapes (&#64;, https&#58;//, https\://), as a renderer would.
    Only http and https links to allowed hosts stay: mailto:, javascript:, data:, tel: and the other link schemes are removed wherever they stand, and any other scheme where markdown, HTML or Slack read a link target ([text](x:...), [ref]: x:..., <x:...>, href=). Object keys are names, not text: a request whose key carries a secret, a mention or a link is refused;
  4. waits for a person where approval is not false: the job shows an approval of kind outputs on the Approvals page, the job's page and oz0 approval list, with the checked requests as its details;
  5. applies what was approved: it posts each request as JSON to the output's http.url, signed with the tenant secret http.secret as X-Oz0-Signature: sha256=<HMAC of the body>, with Idempotency-Key: <job>/<seq>. That secret is a signing key bound to the output's host (oz0 secret set LABELS_HOOK --host hooks.example.com --signing): any other secret of the tenant is refused when the routine is saved, and the edge posts nothing with it, so no output's signature tests the value of a password or token.
    It follows no redirect and uses no proxy, and it refuses targets that resolve to a private, loopback or link-local address (cloud metadata among them), except in dev mode or in networks the cluster lists with oz0 server start --routine-output-allow-net 10.20.0.0/16. When a target answers with an error, the output shows the status; the answer's body stays in the edge's log.

The edge records the decision itself, before the job hears of it, and applies only what needs no approval or what a person approved through it: a node can deny, never approve. A decision covers the requests the approval showed, and the approval shows what the edge recorded. The edge takes a fire's queue once, from the fire's own hand-over step while it runs on its node, and refuses a different queue later, so nothing else can add a request to a fire. An output without http is checked and recorded on the job, and applied nowhere.

What the receiver gets:

{"tenant": "default", "routine": "triage", "version": 3, "job": "rt.triage.github.d3", "seq": 0,
 "output": "add_labels", "arguments": {"labels": ["bug"]}, "ai_label": "AI agent"}

ai_label is the tenant's AI label for the routine's agent: an agent wrote the request, so a receiver that posts it where people read it (a comment, a chat message) shows the label with it. It is left out when an admin turned the label off.

The receiver does the vendor-specific part, such as calling GitHub's API, so the platform's core stays neutral. Each output's state is on the job: refused (with why), pending, ready, approved, rejected, applying, applied (with the receiver's status), failed, recorded or dry_run. job get prints them; routine outputs <name> lists a routine's latest.

Outputs applied during a fire

Some changes are needed to go on: a routine that opens a draft pull request wants to wait for that pull request's CI, which needs its number. An output with apply: now is applied when the agent calls oz0.output, not after the run:

tools: [oz.wait_for_event]
outputs:
  - name: open_draft_pr
    description: Open a draft pull request; answers with its number and URL
    schema:
      type: object
      properties: { repository: { type: string }, head: { type: string }, title: { type: string } }
      required: [repository, head, title]
    apply: now
    result_schema: { type: object, properties: { number: { type: integer }, url: { type: string } }, required: [number] }
    http: { url: "https://forge-bridge.example.com/pulls", secret: FORGE_BRIDGE_HOOK }
  - name: comment
    description: Comment on the pull request
    approval: false
    http: { url: "https://forge-bridge.example.com/comments", secret: FORGE_BRIDGE_HOOK }

The request takes the same path as at the end of a run: the edge checks it, asks a person where approval is not false (the agent's turn waits for the decision), posts it with credentials only the edge holds, and records it in the audit log as routine.output. The agent then reads what came of it, and the receiver's answer: a JSON object of at most 16 KB, which must match result_schema when the output has one, with every tenant secret's value redacted. It reaches the agent as data from outside, never as instructions:

Applied open_draft_pr (HTTP 201).

<output_result output="open_draft_pr">
{"number": 13, "url": "https://forge.example/octo/demo/pull/13"}
</output_result>

With the number, the agent waits for the pull request's check suite with oz.wait_for_event and comments when CI is done, all in one fire. Only the routine decides which outputs are applied now: the edge refuses a request handed over early for an output that is applied after the run. A flow's output: step for such an output returns {applied, state, result, answer}, so a later wait_for_event step can use ${{ steps.<id>.answer.number }}; a refused, failed or rejected output fails the step.

A fire that started before a node had this runtime queues such an output for its end, as before. When the platform cannot apply such an output at once (the edge is unreachable for a few minutes), the agent is told so. An output the edge never recorded can be handed over again; one it recorded is applied when the fire ends, or shown in the end's approval if it still waits for a person.

Copyright © 2026