Skip to content

Workflows

github-actions[bot] edited this page Oct 2, 2026 · 11 revisions

Workflows: triggers, steps, hooks

A trigger is four keys: on: (what fires it), filter: (whether it fires, and for which repos — one composable predicate whose YAML shape is its boolean structure), steps: (the workflow), and hooks: (lifecycle actions). Plus optional group: (Grouping), policy: (Policy), name: (a variant label for dedup state; required for conductor run), and enabled:.

on: takes one <connector>.<event>, the built-in manual source (conductor run <name> fires it on demand), or a list of sources fanning into the same steps — each item a bare conn.event or a one-key map conn.event: { filter, policy, hooks } scoped to that source. See Configuration for the list grammar and merge semantics.

x-templates:
  fixer: &fixer { type: agent, workspace: worktree }

triggers:
  - on: gh.merge_conflict
    steps:
      - { <<: *fixer, id: fix, prompt: "Resolve the conflict on {{.repo}}#{{.pr}}." }
    hooks:
      - { at: start, uses: slack-ops.post, options: { text: "on it: {{.repo}}#{{.pr}}" } }
      - { at: done,  uses: slack-ops.post, options: { text: "resolved {{.repo}}#{{.pr}}" } }
      - { at: fail,  uses: slack-ops.post, options: { text: "failed: {{.error}}" } }

Named and qualified triggers

triggers: also takes a map, where the key is each trigger's stable address. A key that reads as <source>.<event> implies on:, which disambiguates across sources for free:

triggers:
  github.pull_request:  { steps: [ { id: review, type: agent, prompt: "…" } ] }
  gitlab.merge_request: { steps: [ { id: review, type: agent, prompt: "…" } ] }
  pagerduty.incident:   { steps: [ { id: triage, type: agent, prompt: "…" } ] }

A bare event name is not an identity — two connectors can publish the same event, and a pack needs each of its triggers addressable so you can override exactly one. The key is that address.

For two triggers on the same source.event, give them free names and set on: explicitly:

triggers:
  review:    { on: github.pull_request, steps: [ … ] }
  autolabel: { on: github.pull_request, steps: [ … ] }

A free-named key with no on: is an error: only a source.event key implies the event.

Instances: array instead of object

A trigger's value is polymorphic. An object is one trigger; an array is several instances that each fire independently:

triggers:
  review:
    - { on: github.pull_request, filter: { repo: [me/app], label_any: [ready] } }
    - { on: github.pull_request, filter: { repo: [me/api] } }

There is no instances: keyword and no per-instance name. An instance's identity is its content — its filter: is what makes it distinct — so its internal handle is derived from that content. Reordering the array, or reordering keys within an entry, does not move an instance's dedup or attempt state. Two byte-identical entries are an error rather than one trigger silently written twice.

Step forms

A step is one of seven forms (all share id and if):

  • type: agent — dispatch an agent: prompt, checkout, output_schema, background (+ handoff, see Hand-offs), workdir, env, and an optional gate: on the agent's proposed change (Gates). prompt is optional: omit it and conductor hands the agent the event itself — a connector-neutral JSON object (source, kind, target, and the event's context) under a bare "Act on this event:" — and the agent infers the task. Write an explicit prompt only when you want to steer it. A foreground agent step with a local worktree also outputs its proposed diff and workdir (Runs). To re-request a reviewer after a fix, add a uses: <conn>.rerequest_review step (see Verbs) rather than a field on the agent step. By default it pings only reviewers whose latest review is CHANGES_REQUESTED on an older commit than the PR head and who aren't already requested, on an open PR — never someone who has since approved (only_outstanding: false re-requests unconditionally). It skips — never fails the flow — when nobody requestable is left: the PR author, review bots (…[bot]), anyone GitHub rejects as not a collaborator, or nobody with changes still outstanding. It may also carry model: (a fleet, a model id, a wildcard, or an inline { any, required }) and runtime: (a runtimes: entry to pin it to) — see Model selection.

    output_schema is a conductor-owned contract, not a passthrough to the runtime's own structured-output flag. On the paseo runtime it first tries paseo's native support; if that provider can't do it (or returns something that doesn't validate), conductor transparently re-runs with the schema injected into the prompt instead, extracts and validates the JSON itself, and gives the model one corrective retry if the first reply doesn't parse. Once a given runtime/provider/model is seen failing native support, later dispatches for it skip straight to that fallback. On a controller runtime (cli, acp, opencode — which have no native structured-output flag) conductor injects the directive into the prompt from the start and does the same conductor-side extract/validate/retry. Either way the contract is identical and always on — there is no config to disable or select it — so the step's outputs are populated the same; the only visible difference is the number of turns a slow/incapable provider takes. This means an AUTO review judge can run directly on a bare-CLI runtime and still hand structured outputs to the next step.

  • type: command — a host command (POSIX sh semantics; argv list). With host: it runs over SSH and outputs {stdout, stderr, exit_code}.

  • use: <engine> — an inline code step (Code-Steps); use: cli + command: runs an argv. run: is the same key.

  • uses: <conn>.<verb> — a service verb (Verbs). This includes the always-on data, memory, and artifact verbs: kv.*/sql.* over stores:, memory.* over the memory: section (Memory), and blob.* (Binary-Data).

  • call: <name> — a reusable workflow call (below). workflow: is the older spelling of the same field and still parses; a step-level use: used to mean this and is migrated to call:.

  • team: — one task split across a planner, parallel workers in isolated worktrees, an optional critic, and a reconciler (Teams).

  • a helper step — conductor's own flow control, no agent/engine/verb/command behind it: sleep: (pause), log: (a run-log breadcrumb), set: (publish values as outputs), assert: (fail unless an expr holds), fail: (stop with a message), wait_for: (poll a read verb until a condition or timeout). See Steps.

An agent step carries its own BEHAVIOR — guidance, skill, memory opt-in, workspace, timeouts, isolation, model, runtime — and shares it with other steps through a YAML anchor (<<: *base, parked under a top-level x- key). There is no top-level steps: section and no agents: block: a step lives where it runs, and anything that must POINT at one — a team: role, a pack overlay — addresses it as <workflow>/<step-id> (or <workflow>[<n>]). See Steps and Reuse.

Agent steps have two extra memory hooks (when a memory: section is configured): a remember: block in the agent's final output persists post-run with the run's provenance (the output contract), and an opted-in step (memory: true) gets the scoped memories injected into its prompt. The opt-in gates BOTH directions — a step that did not ask for memory cannot write to it either. See Memory.

An agent step carrying a session: block (or running on a runtime that has one) participates in session affinity: events rendering the same key reach one live agent as follow-up prompts instead of fresh spawns — see Steps. A follow-up returns { agent_id, ... } like any agent step; on paseo its output is empty (the prompt is queued to the live agent), so steps that read the agent's structured output should not assume a keyed session.

Context and scope

Templates and if: conditions address:

  • Trigger context — the event's published facts ({{.repo}}, {{.comment_body}}, {{.slack.channel}} — see conductor schema <conn>).
  • Step outputs — {{.<stepid>.<field>}} from any PRIOR step (the legacy {{.steps.<id>.outputs.<field>}} spelling also resolves).
  • Secrets — ${ENV} values and vault reads: {{ vault "house" "gh" }} or {{.vaults.house.gh}} (tainted, redacted — see Secrets; the legacy {{.secrets.<name>}} block was retired and auto-migrates).
  • The batch — {{.group.*}} when the trigger groups.

Scope is positional: a step sees the trigger context plus every prior step's outputs. Hooks see the same, scoped to when they fire — at: start the trigger context only; at: done everything; at: fail everything completed plus {{.error}} and {{.failed_step}}. A step-level hook is scoped to its step (its at: done adds that step's own output). conductor validate resolves every reference against the scope at its position — a start hook reading a step output fails at load, not at 3am.

if: uses the pinned expression set (comparison, &&/||/!, contains(), exists(), default(x, fallback), coalesce(a, b, …)), with paths written bare or as {{.path}}. default/coalesce yield the first present, non-empty argument (nil and "" are empty; 0 and false are real values) and work bare or as a comparison's left side: default(sev, "low") == "high". Templates get the same two as functions — {{.sev | default "low"}}, {{coalesce .a .b "z"}}.

Hooks

hooks: entries are verb action units {at, uses, options, if, id} at start (on match, before steps, synchronous — before any worktree is provisioned or agent launched), done (steps succeeded), fail (the run failed, however it failed — see Failure paths), or stop (the run's target went away under it — see Stops and shutdowns); multiple per phase run in order. Hooks nest on steps too — the same unit under a step's own hooks: fires around that step, so a step can announce itself, post its result the moment it finishes, or handle its own failure. A failing step fires its own at: fail hooks, then (unless it sets continue_on_error) the workflow's. Hook verbs are best-effort: logged and audited, never fatal.

A hook's uses: may be a connector verb OR workflow.run (options: { name: <workflow>, with: {…} }, or inline options: { steps: [ … ] }) — so a hook can run a whole ordered workflow, with if:, agents and helpers, not just one verb.

The hook contract

Every hook — at every phase — receives a uniform hook object describing the lifecycle moment, so a handler parses the same shape regardless of phase:

field when meaning
hook.phase all start | done | fail | stop
hook.status all running | ok | failed | stopped
hook.run_id all the workflow run id
hook.step step-level the step the hook is scoped to
hook.failure fail the failure sub-object (below)

On fail, hook.failure carries { kind, error, step, gave_up } — kind is ordinary, gave_up (retries exhausted, or a dispatch that never reached a working runtime; the same signal as the escalate event), parked (see below), internal (a panic), or no_progress. error is the redacted error text — for anything you post publicly, use {{.run.reason}} instead. A no_progress failure is a fixer step marked expect_push: true that ran cleanly but left its work unlanded — a non-empty proposed diff that never reached the remote, so the PR didn't move and the fix didn't take. It also carries hook.failure.agent_summary (the agent's own reasoning) and hook.failure.diff, so a handler can decide (close the dead PR, comment the reason, escalate). The legacy flat {{.error}} / {{.failed_step}} stay populated for back-compat. A handler branches on it, e.g.:

hooks:
  - at: fail
    uses: workflow.run
    options:
      name: on-failure
      with: { failure: "{{.hook.failure}}", repo: "{{.repo}}", pr: "{{.pr}}" }

Run facts

Workflow-level hooks (not step hooks) also get run, what they need to say what the run did to its target:

field meaning
run.start_sha the target's head when the run started, read fresh as the run begins (after any wait for an agent slot), so not the head the event saw
run.head_sha the target's head when the hook fires, read fresh (at start, the same as start_sha)
run.head_short head_sha's first 7 characters
run.pushed the head moved during the run (both ends known and different)
run.reason at fail: a short, public-safe phrase. It is the agent couldn't be started, the change was never pushed, timed out, step "<id>" failed, parked after repeated tries — needs a human or new commits, internal error, or the run failed, and never the error text. At stop: the PR merged or the PR closed. "" otherwise

A head is the target's current revision as its connector knows it (a pull request's head commit on github). It's read only when the trigger declares a hook for that phase, so a hookless trigger costs nothing. A target with no head, or a read that fails (logged and audited), leaves the shas "" and pushed false. run and hook are reserved names in hook scope: they shadow a step with that id there.

Failure paths

Every way a run fails fires its workflow-level at: fail hooks:

  • a dispatch that never came up (an unknown controller, a worktree or workspace that failed to provision, an agent that crashed before starting), which also escalates
  • a failed step, a step timeout:, a gate that discards, and an expect_push fixer that left its change unpushed (no_progress)
  • a panic inside the run (kind: internal); the hooks fire, then the panic carries on to the engine's recovery
  • an engine park. A (target, kind, head) that kept failing past max_attempts_per_head is parked: it won't be retried until new commits arrive, and it never reaches a run. On the pass that parks it, the engine fires the trigger's at: fail hooks itself, with hook.failure.kind: parked (gave_up: true), run.start_sha = the head it parked at, run.head_sha = the head now, and run.reason = the parked phrase. No start or done hooks fire, since nothing started. Later passes over a parked tuple stay silent.

The one exception is a PR closing under its fixer. That run is stopped, not failed, so its stop hooks fire instead (below).

Stops and shutdowns

Two endings are not failures:

  • Stop: the target went away. The PR merged or closed while a fixer was on it, so conductor stopped the agent; any push from there would land on a dead branch. The run is recorded stopped. Its at: stop hooks fire (step-level for the step that was running, then workflow-level) with the run facts and run.reason = the PR merged / the PR closed. No done or fail hooks fire, and nothing is notified. This is the one moot ending a run can have: a trigger whose PR closed while it was still waiting for an agent slot never starts at all, so it has nothing to clean up. Use it to take down what start put up:

    - { at: stop, uses: gh.set_status,
        options: { repo: "{{.repo}}", sha: "{{.run.start_sha}}", state: success,
                   context: "{{.me.login}} / comment", description: "stopped — {{.run.reason}}" } }
    - { at: stop, if: reaction_subjects, uses: gh.react,
        options: { repo: "{{.repo}}", pr: "{{.pr}}", subjects: "{{.reaction_subjects}}", content: eyes, remove: true } }
  • Shutdown: the daemon went away. The run is interrupted, not ended: its record is kept and it resumes on restart, ending there with whichever terminal hooks apply. So no terminal hook fires (done, fail, or stop, at either level), nothing is notified, and the run is recorded interrupted (audit workflow_interrupted). Only a cancellation of the run's context counts as a shutdown. A run whose own deadline runs out (a step timeout:, a timed one-shot run) timed out, and that's a failure.

stop hooks are best-effort, like every hook.

Control flow

  • if: — skip the step when false (skips are audited).
  • for_each: <ref> — run the step once per element; {{.item}} and {{.index}} in scope; parallel: true fans iterations out concurrently (bounded). At runtime the collected outputs land under {{.<id>.items}} / {{.<id>.count}} (on a for_each verb step, validate checks later references against the verb's own output schema — read the source list instead; see Examples).
  • parallel: [ [steps…], [steps…] ] — concurrent branches, joined before the next step; branch step outputs merge into the parent scope (ids must not collide).
  • retry: { max, backoff } — re-run on error; retry: { while_output_matches, interval, timeout } — re-run while the output still says "not ready" (the legacy defer-retry).
  • timeout: — bound the step.
  • continue_on_error: true — record {error, failed: true} as the step's outputs and keep going.

Failure and resume

A step error stops the workflow (fail hooks fire, the failure is audited with the step named, report shows where it stopped). Runs checkpoint each completed top-level step in runs.json: a daemon restart resumes AFTER the last completed step, so a slack.post that already ran never re-fires; the interrupted step re-runs (at-least-once).

A trigger (or a reusable workflow) may also set a default gate: for every agent step it contains — the step's own gate: wins (Gates).

Reusable workflows

workflows: holds named, parameterized step lists. A workflow may also extends: another workflow to inherit its inputs/outputs/gate (steps replace when set) — see Reuse.

workflows:
  assess-and-post:

```yaml
workflows:
  assess-and-post:
    inputs:
      repo:    { type: string,  required: true }
      pr:      { type: integer, required: true }
      channel: { type: string,  default: "#reviews" }
    outputs:
      decision: "{{.triage.decision}}"
    steps:
      - { id: triage, type: agent, name: planner, checkout: none,
          output_schema: { type: object, required: [decision], properties: { decision: { enum: [auto, manual] } } },
          prompt: "Assess {{.inputs.repo}}#{{.inputs.pr}}." }
      - { id: ping, if: "{{.triage.decision}} == manual",
          uses: slack-ops.post, options: { channel: "{{.inputs.channel}}", text: "needs a human" } }

triggers:
  - on: gh.review_requested
    steps:
      - { id: a, call: assess-and-post, with: { repo: "{{.repo}}", pr: "{{.pr}}" } }
      - { id: auto, if: "{{.a.decision}} == auto", uses: gh.submit_review, options: { … } }

Inside, inputs read as {{.inputs.<name>}}; the workflow also sees the trigger context and its own steps — but NOT the caller's other step outputs (pass those via with:). The caller reads declared outputs: off the call step's id. A workflow may call another workflow; validate rejects cycles, unknown/missing inputs, type mismatches, and outputs referencing steps that do not exist.

A workflow may carry a description: — with its declared inputs/outputs it is self-describing (conductor schema, workflow.list, and a choosing agent all read it). workflow: may also be a templated name (call: "{{.pick}}") resolved at runtime against the workflow set (config + saved): the static checks don't apply to a dynamic name, so it's guarded by a runtime depth cap (8) and a clear unknown-name error naming the set.

Agent-driven workflows

An agent can program conductor (#36 §11): given a goal it emits a plan of ordinary steps, conductor runs them deterministically (no further tokens on the happy path), and the agent re-engages only on failures or to promote a recurring pattern. Everything below runs under Policy's agent_authored block — no block, no plans.

Plan (emit), two ways. Every runtime can end its output with a plan: block — a fenced ```plan YAML step list, or a plan: key in a JSON (output_schema) output. Conductor validates it against the connector schemas, admits it through the guard, and runs it in a child scope that has NO named secrets or preloaded vault values. Runtimes with live tools (ACP) additionally get run_step — author and run ONE step mid-run, same guard — and workflow_list, over the same conductor MCP server the memory tools ride (see Memory); paseo and remote sessions use the output contract.

Choose (don't always author). workflow.list is the catalog: every config + saved workflow's name, description, inputs, and health. workflow.run { name, with, reason } runs the pick — the rationale is audited (workflow_choice) — and workflow.run { steps } runs an inline plan under the full guard. Memory (§9) informs the pick; repetition collapses to recognize → pick → run.

# Recognize → pick → run: the agent consults the catalog and runs the fit.
triggers:
  - on: gh.issue_matched
    steps:
      - id: triage
        type: agent
        name: planner
        prompt: |
          Goal: handle "{{.title}}". Consult the workflow catalog and prior
          memory; if a workflow fits, run it and say why. Only author fresh
          steps for genuine novelty. Reply with a ```plan block, e.g.
          - uses: workflow.run
            options: { name: assess-and-post, with: { repo: "{{.repo}}", pr: "{{.pr}}" }, reason: "..." }

Resume. An agent-step plan checkpoints its committed progress in conductor's own state (plans.json): a daemon crash or auto-update mid-plan resumes AFTER the last committed step — the agent is not re-dispatched and committed side effects never re-run (the restored plan is re-guarded under the current policy; vault-read outputs re-resolve rather than persist). The workflow.run { steps } and live run_step surfaces stay at-least-once, as do nested workflow calls and parallel branches.

Supervise. A failing plan step routes back to the authoring agent's session (§10) as a follow-up with STRUCTURED context — the failed step, the error, executed steps and their outputs, the remaining steps. The agent replies with a revised plan; conductor re-validates, re-guards (the classes the run's original approval granted stay usable; NEW approval-gated work rejects), splices it in, and resumes from the failed step — committed side-effecting steps never re-run. After max_revisions rounds the run compensates and escalates needs_input. A step marked escalate_to: agent checks in on success too. Supervision needs the authoring agent to keep sessions (session: on its profile); without one, failures go straight to compensate + escalate.

Compensate. A plan step may declare compensate: (a nested step — its undo). On terminal failure the committed steps' compensations run in REVERSE order, best-effort, audited (plan_compensate).

Promote. workflow.save { name, description, steps } persists a durable, versioned reusable workflow with provenance — a catalog candidate for Choose next time. Trust is earned: a saved (or newly revised) workflow is UNREVIEWED — it dry-runs freely but refuses a real run until conductor workflows review <name> (or trust: full). Conductor tracks each saved workflow's success rate; a rotting one (≥3 runs, <50% success) is flagged and deprioritized in the catalog. conductor workflows ls shows it all.

# Plan → recover → promote: fix now, and keep the pattern.
triggers:
  - on: gh.failing_checks
    steps:
      - id: fixer
        type: agent
        name: planner               # session: on the step → supervised revisions
        prompt: |
          CI failed on {{.repo}}#{{.pr}}. Emit a ```plan that diagnoses and
          fixes it (allowed verbs only; compensate: where a step has an undo).
          If this pattern recurs, also include a workflow.save step promoting
          it — future runs will pick it from the catalog with zero planning.

Audit & honesty. Every plan run is audited: the admission gate (allow/approve/trust), rejections with reasons, per-step outcomes, revisions, compensations, the choice rationale — and the deterministic-vs-hybrid classification (a plan with no sub-agent steps is truly token-free; one with them is hybrid, with its sub-agent count and approximate token spend bounded by limits.tokens).

File-based references

A call: resolves three ways; all are checked at load:

- { call: review-flow }                                     # by name — defined inline or in any imported file
- { call: review-flow, import: ./workflows/review.yaml }    # name + the file it lives in (no section import needed)
- { call: ./workflows/review.yaml }                         # a bare file path, when the file defines ONE workflow

A referenced file holds a workflows: block or bare name→definition entries; relative paths resolve against the config file's directory. The bare-path form errors on a multi-workflow file (name one with call: + import:). A workflow can also keep its name in the config with its body in its own file — workflows: { review-flow: { import: ./workflows/review.yaml } } — and section-level splitting (workflows: { imports: [...] }) is Configuration.

Steps can also act on conductor itself — uses: conductor.update / pause / resume / restart / reload / run (and gh.sweep) — and react to it: triggers on the conductor.* lifecycle events replace the old notify: block. Hooks nest on those steps like any other, so a gated self-update runs drain → update (announced at start/fail) → smoke-test; see Configuration and Notifications.

Related: Connectors · Verbs · Code-Steps · Grouping · Hand-offs

Clone this wiki locally