---
title: "Routine permissions and outputs"
description: "Review what a run may read and the changes it may queue."
canonical_url: "https://docs.oz0.ai/build/parts/routines/outputs-and-permissions"
---
# 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](https://docs.oz0.ai/spend/budgets-and-quotas) (`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](https://docs.oz0.ai/govern/audit-trail) as `run.refused`.

### Sandbox

Every fire runs as a job started by an outside event (see [Security](https://docs.oz0.ai/concepts/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.<br />

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.<br />

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:

```json
{"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](https://docs.oz0.ai/govern/ai-labels) 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:

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

```text
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.


## Sitemap

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