Build plugins

Flows

Declarative steps that chain tools, agents, conditions and approvals.

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.

flows/support-triage.yaml
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:

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

flows/support-triage.yaml
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 }}" }
Terminal
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) 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. 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.

A webhook's body is text from outside. An issue title or a ticket can carry instructions, and an agent may follow them. Until harness sessions run in a sandbox and pass hooks and approvals (ADR 0030), keep agents with a shell or write tools, such as a harness agent with 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.tools list only Read, Glob, Grep, LS, NotebookRead or TodoWrite.
An agent on any other harness, or on the Claude harness with its default tools (which include 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
A step whose agent is an expression, such as 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:

StepDoesIts output, in steps.<id>
tool: <tool>.<function> with inputCalls a tool function on the flow's home node, with the tenant's hooks and approvalsThe tool's structured result, or its text (parsed when it is JSON)
agent: <name> with inputRuns an agent as a child job and waits for its answerThe agent's answer
approval: with details and timeoutWaits 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 the output: above.
  • CEL is strict about types. 2.0 * 3 is an error; write 2.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.

Steps that run in parallel, retries and error branches come later.
Copyright © 2026