Parts

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.

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:

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

job watch, job get, job cancel, the console's job page, the 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:

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 }}" }
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; a delivery over it gets 429.

While a kill switch 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.

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

Start one from an event

A flow can start on the events of an event source instead, such as GitHub's pull requests:

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.

Previous section links
Copyright © 2026