---
title: "Write a flow"
description: "Define a flow and start it directly, from a webhook or from an event."
canonical_url: "https://docs.oz0.ai/build/parts/flows"
---
# Write a flow

> Define a flow and start it directly, from a webhook or from an event.

For: plugin authors.

Define a flow in YAML to run a sequence of tool calls, agent jobs and approvals, with conditions where needed. The platform's flow workflow executes the steps and preserves Temporal determinism. Release a new flow version with its plugin.

```yaml [flows/acme-triage.yaml]
name: acme-triage
description: Routes a ticket to the right team
input_schema: schemas/ticket.json        # optional
steps:
  - id: classify
    tool: acme_triage.classify
    input:
      ticket: ${{ input.ticket }}
      teams: [billing, technical, sales]
  - id: route
    when: steps.classify.confidence >= 0.8
    agent: acme-${{ steps.classify.team }}
    input: ${{ input.ticket }}
  - id: ask_human
    when: steps.classify.confidence < 0.8
    approval:
      details: "Route ${{ input.ticket.subject }} to ${{ steps.classify.team }}?"
      timeout: 4h
output: "${{ has(steps.route) ? steps.route : 'a person decides' }}"
```

List flows in the manifest with `flows: [flows/]`, a folder of `*.yaml` files or single files.

## Run one

A flow runs as a job, by name, like an agent:

```bash [Terminal]
oz0 run acme-triage '{"ticket": {"subject": "Refund", "body": "..."}}'
```

`job watch`, `job get`, `job cancel`, the console's job page, the [runtime API](https://docs.oz0.ai/build/apps/runtime-api) and cost per branch work for flow jobs as for agent jobs; `oz0 catalog` lists flows with their steps. A flow job has `flow: true`.

## Start one from a webhook

A flow with a webhook trigger starts when a service posts to the edge:

```yaml [flows/acme-triage.yaml]
name: acme-triage
trigger:
  webhook: { secret: ZENDESK_WEBHOOK }   # a tenant secret, which only the edge reads
steps:
  - id: classify
    tool: acme_triage.classify
    input: { ticket: "${{ trigger.body.ticket }}" }
```

```bash [Terminal]
oz0 secret set ZENDESK_WEBHOOK --from-file ~/.secrets/zendesk-webhook --signing --host edge.example.com:7443
```

Set it as a signing key bound to the edge's address the service posts to: 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. A secret a connection of the flow's plugin saved is taken as it is, unless that connection sends it as a credential (its token, the key its tokens are signed with, its client's secret or its refresh token), which goes only where the edge sends it.

Any other secret, a credential above all, checks nothing, and every request gets the `401` a wrong signature gets; the edge's log says why.

The service posts JSON to `https://<edge>:7443/v1/flows/<tenant>/<flow>`, here `https://edge.example.com:7443/v1/flows/default/acme-triage`, and proves it knows the secret in one of two ways:

- `Authorization: Bearer <secret>`, as Zendesk and most services can send;
- an HMAC-SHA256 signature of the body with the secret as its key, in `X-Hub-Signature-256: sha256=<hex>` (GitHub's header) or `X-Oz0-Signature`.

The body becomes the flow's `input` and `trigger.body`; the request's headers, without credentials and signatures, are `trigger.headers`. The edge answers `202` with the job's ID. A signed request is named by what its signature covers, the job `hook-<flow>-sha256-<digest>`: the same signed body sent again is the same job, whatever its delivery headers say, and one sent after the job ended gets `200` with that job's ID instead of a second run.

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>">`, refused (`401`) more than five minutes from the edge's clock. A bearer secret is the secret itself, so whoever holds one can send anything: with one, a delivery ID (`Idempotency-Key`, `X-GitHub-Delivery` or `X-Request-Id`) names the job; prefer a signature. Bodies are at most 1 MB. The jobs a webhook starts are charged to `service:webhook/<flow>`, which can have a [quota](https://docs.oz0.ai/spend/budgets-and-quotas/charging#who-a-job-is-charged-to); a delivery over it gets `429`.

While a [kill switch](https://docs.oz0.ai/govern/kill-switches) stops the flow, a delivery gets `503` with the switch's reason (`oz0_killed`). A flow whose webhook secret is not set yet refuses every request with the `401` a wrong signature gets; the edge's log says which it was.

<warning>

A webhook body is outside input. An issue title or ticket can contain instructions that an agent might follow.

A webhook-started job chooses its home only from tenant nodes that have the flow and [sandbox tier 1](https://docs.oz0.ai/concepts/security/sandbox-tiers#sandboxes). Tool steps run in their own sandboxed server. Child agents and harness sessions also run on tier-1 nodes and reach only their allowlisted hosts. A step fails with an explanation if no tier-1 node takes it within 10 minutes.

An agent with shell or write tools, such as a [harness](https://docs.oz0.ai/build/parts/harnesses) agent with `Bash`, `Write` or `Edit`, may process that input only in tier 1 without the plugin's secrets. `plugin install` refuses the flow unless a tenant tier-1 node has the labels that agent requires. Pydantic AI agents also pass hooks and approvals on each tool call.

`plugin lint` reports paths from a webhook flow to agents whose harness sessions need tier 1, through both flow steps and delegation. The extra shell-or-write install check does not apply to:

- Pydantic AI and other framework agents;
- agents on the Claude harness whose `harness.tools` list only `Read`, `Glob`, `Grep`, `LS`, `NotebookRead` or `TodoWrite`.

An agent on a harness whose tools the platform cannot check, or on the Claude harness with its default tools (which include `Bash`, `Write` and `Edit`), needs an eligible tier-1 node. Without one, install refuses the path. Lint names the path and warns that the sessions need tier 1:

```text
flow fix-issue, which a webhook starts, can reach agent fixer (fix-issue -> fixer): it lists no harness tools, so it gets Read, Write, Edit, Glob, Grep, Bash, which include Write, Edit, Bash
```

A step whose agent is an expression, such as `acme-${{ steps.classify.team }}`, can reach every agent its fixed parts match. The check runs across the tenant's plugins at install: adding either the flow or the agent is refused when the required tier-1 node is missing. A path that was installed before the check existed is reported as a warning.

</warning>

## Start one from an event

A flow can start on the events of an [event source](https://docs.oz0.ai/build/parts/event-sources) instead, such as GitHub's pull requests:

```yaml [flows/github-ci-watch.yaml]
name: github-ci-watch
trigger:
  event: { source: github-events, types: [pull_request], filter: "event.action == 'opened'" }
steps:
  - id: ci
    wait_for_event: { source: github-events, types: [check_suite], subject: "${{ trigger.event.subject }}", timeout: 4h }
```

The event is the flow's `input` and `trigger.event`. The job is `event-<flow>-<event id>`, charged to `service:webhook/<flow>` like a webhook's, and kill switches stop it the same way. An event's text is outside text, as a webhook's body is: everything in the warning above holds for event-triggered flows too. A `wait_for_event` step waits for a further event; see [Waiting for events](https://docs.oz0.ai/agents/waiting-for-events#a-flow-waits-with-a-wait_for_event-step).

## Related tasks

- [Flow steps and expressions](https://docs.oz0.ai/build/parts/flows/steps-and-expressions): Look up step types, error branches and expression behaviour.
- [Expose and explain flows](https://docs.oz0.ai/build/parts/flows/flow-tools): Expose a flow as a tool, save a job as a flow or explain its behaviour.

<legacy-sections>

- <span id="a-built-in-in-a-step">

Section

</span>

: [a built in in a step](https://docs.oz0.ai/build/parts/flows/steps-and-expressions#a-built-in-in-a-step)
- <span id="error-branches">

Section

</span>

: [error branches](https://docs.oz0.ai/build/parts/flows/steps-and-expressions#error-branches)
- <span id="explain-a-plugin-for-an-auditor">

Section

</span>

: [explain a plugin for an auditor](https://docs.oz0.ai/build/parts/flows/flow-tools#explain-a-plugin-for-an-auditor)
- <span id="expressions">

Section

</span>

: [expressions](https://docs.oz0.ai/build/parts/flows/steps-and-expressions#expressions)
- <span id="flows-as-tools">

Section

</span>

: [flows as tools](https://docs.oz0.ai/build/parts/flows/flow-tools#flows-as-tools)
- <span id="save-a-job-as-a-flow">

Section

</span>

: [save a job as a flow](https://docs.oz0.ai/build/parts/flows/flow-tools#save-a-job-as-a-flow)
- <span id="steps">

Section

</span>

: [steps](https://docs.oz0.ai/build/parts/flows/steps-and-expressions#steps)
- <span id="steps-of-an-optional-plugin">

Section

</span>

: [steps of an optional plugin](https://docs.oz0.ai/build/parts/flows/steps-and-expressions#steps-of-an-optional-plugin)

</legacy-sections>


## Sitemap

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