Flows
A flow is a YAML file of steps: call a tool, run an agent, wait for a person, each under a condition if you like. Our own flow workflow runs it, so a flow can never break Temporal's determinism, and a new version of a flow ships with its plugin.
name: support-triage
description: Routes a ticket to the right team
input_schema: schemas/ticket.json # optional
steps:
- id: classify
tool: triage.classify
input:
ticket: ${{ input.ticket }}
teams: [billing, technical, sales]
- id: route
when: steps.classify.confidence >= 0.8
agent: support-${{ 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:
orchestrator-zero run support-triage '{"ticket": {"subject": "Refund", "body": "..."}}'
job watch, job get, job cancel, the web UI's job page, the runtime API and cost per branch work for flow jobs as for agent jobs; orchestrator-zero 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:
name: support-triage
trigger:
webhook: { secret: ZENDESK_WEBHOOK } # a tenant secret, which only the edge reads
steps:
- id: classify
tool: triage.classify
input: { ticket: "${{ trigger.body.ticket }}" }
orchestrator-zero secret set ZENDESK_WEBHOOK --from-file ~/.secrets/zendesk-webhook
The service posts JSON to https://<edge>:7443/v1/flows/<tenant>/<flow>, here https://edge.example.com:7443/v1/flows/default/support-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) orX-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. When the request carries a delivery ID (Idempotency-Key, X-GitHub-Delivery or X-Request-Id), a retry of the same delivery is the same job, and a retry after it ended gets 200 with that job's ID instead of a second run. Bodies are at most 1 MB.
Bash, Write or Edit, out of every flow a webhook starts, and out of the agents those flows delegate to. Pydantic AI agents are safer here: each of their tool calls passes your hooks and approvals.plugin lint and plugin install enforce this. A flow with a webhook trigger may reach, through its agent steps and their delegation, only agents that cannot run a command or change a file:- Pydantic AI and other framework agents;
- agents on the Claude harness whose
harness.toolslist onlyRead,Glob,Grep,LS,NotebookReadorTodoWrite.
Bash, Write and Edit), is refused, with the way the flow reaches it: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
support-${{ steps.decide.team }}, can reach every agent its fixed parts match. The check runs across the tenant's plugins at install: a plugin is refused when it brings either the flow or the agent. A way that was installed before the check existed is reported as a warning.Steps
Each step has an id (lowercase letters, digits and _, so expressions can read it as steps.<id>) and does one thing:
| Step | Does | Its output, in steps.<id> |
|---|---|---|
tool: <tool>.<function> with input | Calls a tool function on the flow's home node, with the tenant's hooks and approvals | The tool's structured result, or its text (parsed when it is JSON) |
agent: <name> with input | Runs an agent as a child job and waits for its answer | The agent's answer |
approval: with details and timeout | Waits for a person; no answer within timeout (default 24 h) is a no | {approved, by, comment} |
when: makes a step conditional; a step whose condition is false is skipped and has no output, which has(steps.<id>) tells. Steps run in order. A step that fails, or an approval that is denied, ends the flow with the step's ID in the error.
The flow's answer is output:, or the last step's output when there is none.
Expressions
Expressions are CEL. They see input (the flow's input), steps (the outputs of the steps so far), job (the job's ID) and trigger (for a webhook, its headers and body; null otherwise).
when:is one expression, true or false.- Everywhere else, a value is literal unless it contains
${{ ... }}. A string that is exactly one${{ expression }}becomes the expression's value, with its type:input: ${{ input.ticket }}passes the whole ticket. Other strings get the values filled in:agent: support-${{ steps.classify.team }}. A map or a list filled into a string is written as JSON, with the keys of maps sorted.
Mind two things:
- Quote what YAML would misread. An expression with
:or a leading{needs quotes, like theoutput:above. - CEL is strict about types.
2.0 * 3is an error; write2.0 * 3.0. Comparing an int with a double (1 >= 0.8) is fine.
plugin lint checks every expression, and that each steps.<id> names an earlier step, so a mistake fails the install, not the first run.