Skip to content

Workflows

A workflow is a versioned YAML (or JSON) definition. One generic interpreter runs it on Temporal. Workflows live in factory repos (workflows/<id>.yaml), and merging a change applies it: each change to a workflow publishes a new version of it (v1, v2, …), which records the commit and file it came from, and runs keep the version they started with. To roll back, git revert: that publishes a new version with the old content. factoryal factory check <dir> validates them before you push.

Anatomy

kind: Workflow
identifier: fix-main-ci           # starts with a letter; letters, digits, . - _
title: Fix repos whose CI breaks on main
owner: platform                    # optional: the team, for team budgets and spend reports
enabled: true                      # false: no new runs, schedules removed, history kept
inputs:                            # JSON-schema-like; validated when a run starts
  repo: { type: string, required: true }
triggers:                          # default: [{type: manual}]
  - type: entity_event
    blueprint: repo
    events: [updated]
    condition: '.after.properties.main_ci_status == "failing" and .before.properties.main_ci_status != "failing"'
    inputs: { repo: "{{ .entity.identifier }}" }
concurrency: { limit: 1, key: "{{ .inputs.repo }}", on_limit: skip }
max_cost_usd: 5                    # optional: fail a run once it has cost this (see Cost cap per run)
replay: { capture: true }          # optional: keep llm (and tool-less agent) steps' requests to replay on other models (efficiency.md#replays)
vars: { team: platform }           # constants, `.vars` in every expression (see Templates)
steps:
  - id: plan
    type: agent
    agent: planner
    prompt: Plan a fix for {{ .inputs.repo }}.
output: "{{ .steps.plan.output }}" # default: the last step's output

Every step accepts these common fields:

Field Meaning
id Required and unique. Later steps read its result as .steps.<id>.output.
name Display name.
when A jq expression. The step is skipped when it is false or null.
timeout e.g. 30s, 10m, 2h.
retry {max_attempts: 3, initial_interval: 5s, backoff: 2.0}. agent steps default to 1 attempt.
on_error fail (default) or continue. With continue, the run goes on with the error recorded.
queue Override the task queue an activity step runs on (e.g. a dedicated llm-batch pool).

Templates and expressions

Values are jq evaluated against the run context:

Path Holds
.inputs the run's validated inputs
.steps.<id>.output a finished step's output
.run {id, workflow, version}
.trigger what started the run (event, schedule, parent run, ...)
.vars the workflow's vars: constants, also in trigger conditions and inputs, concurrency keys and wait conditions
.item, .index the current item inside map and parallel + for_each
  • "{{ expr }}" as the whole string yields the raw JSON value (a list stays a list).
  • "text {{ expr }} text" interpolates as text.
  • when, if, condition and items are bare jq: '.steps.triage.output.severity | IN("high", "critical")'.
  • Objects and lists are rendered recursively, so templates work anywhere in a step's fields.

vars holds values several places need, such as an account id or a list of trusted users, so they are written once. They are plain values, not templates, and not secrets: they are stored in the definition and visible to anyone who can read it. A factory can set vars in its factory.yaml for every workflow it holds; a workflow's own vars win over the factory's (Factories). A wait step's vars are added on top in its conditions. .vars is reserved: it is added to the data trigger conditions see, next to the event's own fields.

Step reference

Type Does Key fields Queue
llm One Claude call. With output_schema the output is parsed JSON prompt, system, model, effort, routing (below), max_tokens, output_schema llm
agent Runs a factory agent with catalog tools, limited to its scopes (Agents) agent, prompt, output_schema, max_turns, memory, model/effort/skills overrides, routing llm
code A coding agent changes a repo, and the platform opens the PR (Agents → Coding agents) agent, repo, task, rubric, budget_usd, mode, backend, environment, extra_repos, model/effort/skills overrides, routing coding
approval Pauses until a human decides (below) message, approvers, expires, on_expire, on_reject, jira, reviews —
wait Pauses until a matching catalog or webhook event arrives (below) for, waiting_on, reason, vars, expires, on_expire —
http Calls a URL url, method (POST), headers, body light
upsert_entity Writes a catalog entity blueprint, entity, merge (true) light
notify Posts to Slack, Linear, GitHub or Jira with the control plane's credentials (Slack and Linear) target, text, plus channel/thread_ts, issue, repo/number, comments/commit_id, pull, replies, status light
github_pull Reads a pull request and its diff with the GitHub App (PR reviews) repo, number, since (a commit, or last_review), exclude, thread/review, max_files, patch_chars light
set Computes values; the rendered values become the output values —
delay A durable sleep duration —
fail Fails the run with a message message —
condition Branches if, then, else —
goto Jumps back to a top-level step target, max_loops (3) —
parallel Fixed branches, or the same steps per item branches or for_each + steps, max_parallel —
map Steps per item, with per-item results items, steps, max_parallel, on_error —
workflow Runs another workflow as a child and waits workflow, version, inputs, timeout —
benchmark Runs a benchmark suite and waits (Benchmarks) suite, configs, tasks, repetitions, scorers light
replay Replays an llm (or tool-less agent) step's captured requests on other models and waits for the judge (Efficiency) step, candidates, sample, since, max_cost_usd, judge, poll light
retro Claude looks back at a finished run and writes a retro; its lessons wait for review (Learning) run, model, effort, lessons (true) llm

A dash in the Queue column means the step runs inside the interpreter itself.

llm and agent steps call the Claude API. With FACTORYAL_LLM_PROVIDER=claude_code the llm workers run them through the Claude Code CLI instead, which can be logged in with a Claude subscription (Lightsail → Claude on a subscription). Agent steps then get their catalog tools from an MCP server (Agents → Agent steps).

Model routing

routing on llm, agent and code steps sends each run to the model that does that kind of work well for the least: a list of rules, each with a when and the model, effort and, on agent and code steps, skills to use (skills is rejected on an llm step). A when is bare jq over the same data as the step's own when (.inputs, .steps, .vars, .item in a map). The rules are tried in order and the first whose when is true replaces what it sets; the rest comes from the step. Only the last rule may leave out when, which makes it the default. With no rule matching, the step runs with its own model, effort and skills.

- id: fix
  type: code
  agent: builder
  repo: "{{ .inputs.repo }}"
  task: "{{ .inputs.task }}"
  routing:
    - when: '.inputs.kind == "dependency-bump"'   # routine: a cheaper model does it as well
      model: claude-sonnet-5-5
      effort: medium
    - model: claude-opus-5-5                       # everything else
      effort: high

The step record has route, the index of the rule that applied (null when none did and the step's own settings were used), also when the step failed, and the run timeline shows it on the step's step.finished event. Steps without routing have none. routing needs at least one rule, each rule at least one of model, effort or skills, and every when must compile when the workflow is saved.

Output schemas

output_schema on llm and agent steps is JSON Schema, or a shorthand that expands to it. In the shorthand each field maps to a type (string, number, integer, boolean), an enum (a | b | c), [<item>] for a list or a nested mapping for an object, each optionally followed by : description. Every field is required and no others are allowed, which is what makes the output dependable for later steps:

output_schema:
  reply: "string: a short reply"     # quote it when it has a description
  replan: boolean
  steps: [{ title: string, details: string }]

expands to {type: object, properties: {...}, required: [reply, replan, steps], additionalProperties: false}, nested objects included.

A mapping with a JSON Schema keyword (type, properties, items, enum, anyOf, $ref, ...), an empty one ({}, any value) or one with only keywords and real values ({ description: Any answer, additionalProperties: false }) is JSON Schema, kept exactly as written, at the top or nested inside a shorthand. Use it for optional fields, open objects, numeric limits or a field named like a keyword. Shorthand mistakes are rejected rather than guessed:

  • keywords mixed with shorthand fields: items: [{ name: string }], required: boolean;
  • an output whose fields are all named like keywords and each hold a shorthand type, such as { title: string, description: string } (a PR's title and body). Write it in full JSON Schema.

One case can't be told apart: a field named type next to fields named like annotation keywords, like { type: string, title: string }, reads as JSON Schema. Use the full form for that.

These checks run when a workflow is saved, and a factory's files are the only way to save one: factoryal factory check, the factory's plan and its apply report them. A version that was saved stays loadable even if the rules change later.

Examples

- id: classify
  type: llm
  effort: low
  prompt: "Classify this issue: {{ .inputs.summary }}"
  output_schema:
    severity: "low | medium | high | critical: how bad it is"
    labels: [string]

- id: record
  type: upsert_entity
  blueprint: issue
  entity:
    identifier: "{{ .inputs.issue }}"
    properties: { severity: "{{ .steps.classify.output.severity }}" }

- id: page
  type: http
  when: '.steps.classify.output.severity == "critical"'
  url: https://events.pagerduty.com/v2/enqueue
  body: { summary: "{{ .inputs.summary }}" }

Control flow

Condition and goto.

- id: check
  type: condition
  if: '.steps.review.output.approved'
  then: [{ id: done, type: set, values: ok }]
  else: [{ id: retry, type: goto, target: build, max_loops: 2 }]

Map runs steps once per item of a list computed at run time:

- id: each-repo
  type: map
  items: "{{ .steps.find.output.repos }}"   # a jq expression or a template yielding a list
  max_parallel: 4
  on_error: continue                         # default fail: the first failure cancels the rest
  steps:
    - { id: check, type: agent, agent: reviewer, prompt: "Review {{ .item }}" }

The output has one {item, status, output, error} per item. With on_error: continue, every item runs, failures appear in the output, and failed_items counts them.

Parallel runs fixed branches concurrently, or the same steps per item with for_each. Its output is a list of each branch's last output. Use it when one failure should fail everything and you don't need per-item results. See examples/demo/projects/delivery/workflows/parallel-review.yaml.

Child workflows compose workflows:

- id: notes
  type: workflow
  workflow: release-notes      # version: N pins one; default is the latest when the step runs
  inputs: { tag: "{{ .inputs.tag }}" }
  timeout: 2h                  # optional: cancel the child and fail if it takes longer

The child is a run of its own, started through the API, so it may queue behind its own concurrency limit. The parent waits on a signal and holds no worker. It also polls every 5 minutes in case the signal is lost. The child's output becomes the step's output. A failed, cancelled or skipped child fails the step, and cancelling the parent cancels the child. The child's cost stays on the child run (the step records it as child_usage), so summing runs never counts it twice. Nesting is limited to 5 levels.

Approvals

- id: approve
  type: approval
  message: "Release {{ .inputs.service }}?\n\n{{ .steps.notes.output }}"
  approvers: [release-managers]   # identities allowed to decide from Slack or Jira
  expires: 72h                    # default: wait forever
  on_expire: reject               # or approve
  on_reject: fail                 # or continue (the output says approved: false)
  reviews: notes                  # optional: the earlier step whose work this approval judges

With reviews, the decision is also recorded as that step's outcome (steps.<id>.outcome: {score, signal, by, approval, at}, 1 approved and 0 rejected): how good a person judged that step's work, which the efficiency report weighs against its cost. The step must come earlier in the same list (not a condition, which does no work of its own), and only a person's decision counts: one made by expires/on_expire records nothing, nor does one whose step hasn't run since the approval last did (a goto or its when skipped it). The approval's own record says which: review: {step, recorded, reason}, with reason expired or not_run. A step that a goto loop runs again records how many times it ran in attempts; its output and outcome are its last attempt's, its usage what all of them used. A later pass whose when skips a step that already ran leaves that attempt's record as it was (status, output, usage, outcome and attempts together), marked last_pass: skipped; running it again starts a fresh record. Expressions in that pass still see the skip: .steps.<id>.status is SKIPPED and its output null.

The output is {approved, by, comment}. A waiting run is WAITING_APPROVAL and holds no worker. It is decided in the workflow's channels, which record who decided. The web UI shows pending approvals read-only.

Where How
API POST /v1/runs/<id>/approval {"step": "approve", "approved": true, "by": "ana", "comment": "..."} (admin token)
Slack /factoryal approve <run id> [step] [comment], as the mapped identity (Slack and Linear)
Jira moving the issue to a status in jira.approve_on / jira.reject_on (Factories → Jira)

Waiting for events

A wait step pauses the run until an event matches one of its for matchers, so one run can own a whole lifecycle: review a pull request, wait for the next push or reply, review again, until it is merged or closed.

- id: wait
  type: wait
  waiting_on: human             # human | ci | external (default): what the run is waiting on
  reason: "Author to address the review on #{{ .inputs.number }}"
  vars: { repo: "{{ .inputs.repo }}", number: "{{ .inputs.number }}" }  # rendered once; .vars in conditions
  for:                          # 1 to 10 matchers; the first one listed that matches wins
    - name: push
      blueprint: pull_request   # an entity change...
      events: [updated]
      entity: "{{ .inputs.repo }}-{{ .inputs.number }}"   # optional: only this entity
      condition: .after.properties.head_sha != .before.properties.head_sha
    - name: reply
      blueprint: review_reply
      events: [created]
      condition: .after.relations.repo == .vars.repo and .after.properties.number == .vars.number
    - name: comment
      webhook: jira             # ...or a webhook delivery
      events: ["comment_created"]
  expires: 30d                  # default: wait forever
  on_expire: continue           # or fail (default)

A matcher's condition is bare jq over the same data a trigger of that kind sees ({event, blueprint, entity, before, after} or {webhook, event, delivery, payload, headers}), plus .vars. The output is {name, event, eventId, at}: which matcher fired and the event it saw, or name: expired when the wait expires with on_expire: continue.

No event is missed between waits. Each run keeps a cursor, the last event it saw: the one that started it (or the latest event when it started), then the event that ended its previous wait. A new wait first looks at the events after the cursor, so a push that lands while the review of the previous one is running ends the next wait at once. "After" is the order the dispatcher handled events in, not their ids: an event whose transaction commits after a later one's is handled, and replayed, after it too. Events the dispatcher hasn't handled yet count as after the cursor, so a run started by hand during a dispatcher backlog can see events from just before it started.

A waiting run is WAITING, holds no worker, and keeps its concurrency slot, so set expires on waits in a workflow with concurrency: otherwise a few abandoned runs can hold every slot. waits on the run lists what it waits for (waitingOn, reason, since), and waited adds up the seconds it has spent waiting, by waiting_on (approvals count as human), so effort can be split into working and waiting time. Waiting time is summed per wait: two parallel branches waiting the same hour count two hours, so a rollup's working time (elapsed − waiting) is a lower bound when waits overlap.

A webhook matcher's output leaves out the delivery's headers (they can carry secrets).

examples/demo/projects/code-review/workflows/pr-lifecycle.yaml is a complete lifecycle: review a PR, wait, review again or answer, until it is merged or closed.

Triggers

Type Starts a run when Context for condition and inputs
manual (default) someone calls the API, CLI or UI —
entity_event an entity of blueprint is created / updated / deleted {event, blueprint, entity, before, after}
schedule a cron expression fires (below) fixed inputs
webhook a verified delivery reaches a webhook source (Factories) {webhook, event, delivery, payload, headers}
budget_event a budget crosses warn_at or is used up (Operations) {event, scope, spentUsd, monthlyUsd, warnAt, month}
benchmark_event a benchmark regresses against its baseline (Benchmarks) {event, suite, agent, benchmarkRun, baseline, configs}
run_event another run finishes (finished), or a PR it opened is merged, closed or gets changes requested (outcome) (Learning) {event, run, workflow, status, error, factory, project, parentRun, costUsd, triggerType}, plus {kind, pullRequest, outcome} for outcome
triggers:
  - type: entity_event
    blueprint: issue
    events: [created]
    condition: '.after.properties.severity != "low"'
    inputs:
      issue: "{{ .entity.identifier }}"
      summary: "{{ .after.properties.summary }}"

Event-triggered runs get an id derived from the event and the workflow, so if the same event is processed twice, no second run starts.

Schedules

triggers:
  - type: schedule
    cron: "0 3 * * 1-5"        # 5 fields, or a shortcut like @daily
    timezone: Europe/Madrid    # default UTC
    inputs: { scope: all }     # fixed inputs for every scheduled run
concurrency: { limit: 1, on_limit: skip }   # a slow run doesn't pile up the next ones

Each schedule trigger is a Temporal Schedule. When it fires, it asks the API to start the run, so scheduled runs get a run row and respect concurrency limits like any other. Each firing has its own idempotent run id. Schedules are synced when a workflow is published, and the dispatcher re-syncs all of them on every reconcile pass. Disabling a workflow removes its schedules. A firing missed by more than 10 minutes (e.g. during an outage) is dropped, not caught up.

Concurrency limits

concurrency:
  limit: 1                     # active runs allowed (waiting for approval counts as active)
  key: "{{ .inputs.repo }}"    # optional: the limit applies per key value
  on_limit: queue              # queue (default): FIFO, started when a slot frees | skip: record as SKIPPED

Queued runs aren't started in Temporal until a slot frees, so they use no compute. They keep the workflow version they were queued with. Slots are taken under a Postgres advisory lock per workflow and key, so concurrent API instances and the dispatcher can't over-admit. Every minute the dispatcher checks active runs against Temporal and closes any whose final status report was lost, so no run can hold its slot forever.

Cost cap per run

max_cost_usd caps what one run may spend, where budgets cap a month. Before each llm, agent, code or workflow step the run checks what it has cost so far, and once that has reached the cap the run fails there (the step shows the error), even under on_error: continue. Free steps still run, so the step that crossed the cap can finish its work (e.g. post its review). A code step stays capped by its own budget_usd.

  • A run can go over by the cost of its last paid step, or, with paid steps running at once (parallel, or map with max_parallel above 1), by all the ones that started before the cap was reached.
  • A child run's cost stays on the child and counts against its own max_cost_usd, not the parent's. The parent's cap only stops it from starting more children once its own steps have reached it, so a run that only starts children is capped only by the children's caps.

Starting and managing runs

Action API CLI / UI
Start POST /v1/workflows/<id>/runs {"inputs": {...}} Workflows → start form
List GET /v1/runs?workflow=&status= Runs
Inspect GET /v1/runs/<id>, GET /v1/runs/<id>/timeline factoryal runs show <id>
Stream GET /v1/runs/<id>/events (Server-Sent Events) the run page, live
Approve / reject POST /v1/runs/<id>/approval Slack, Jira (not the web UI)
Cancel POST /v1/runs/<id>/cancel the run page
Versions GET /v1/workflows/<id>/versions —
Top-level runs only GET /v1/runs?top_level=true Runs (child runs hidden unless ticked)
Without skipped runs GET /v1/runs?hide_skipped=true: leaves out runs a budget or concurrency limit never started; an explicit status overrides it, so status=SKIPPED&hide_skipped=true lists the skipped runs Overview, Runs (skipped runs hidden unless unticked)
Whole lifecycle GET /v1/runs/<id>/rollup: cost, child runs, working and waiting time of the run and every run it started the run page
What is waiting GET /v1/waiting?waiting_on=human: open waits and approvals, oldest first, under their top-level run factoryal runs waiting, Waiting

Timelines, live events and share links are covered in Operations.

Run mode

Every run has a mode, set when it starts and kept by its child runs. Only a live run writes to client systems: it can open pull requests, post comments, reviews and replies, send Slack and Linear messages, and comment on or transition Jira issues.

Mode Started by What it does
live default everything
shadow POST /v1/workflows/<id>/runs {"mode": "shadow"} everything except writing to client systems: run it alongside the live workflow and compare
dry_run POST /v1/workflows/<id>/runs {"mode": "dry_run"} the same, to try a workflow out
benchmark a benchmark's trials and scorer runs the same
replay POST /v1/replays re-runs captured llm steps

In a run that isn't live, the API refuses those writes whatever the workflow says (403, code run_not_live), and the step still completes: - a code step records the branch the agent pushed (pr_url is null, unpublished says why); - a notify step outputs {"sent": false, "unpublished": "..."}.

Agents still push their branches. A code step's own mode: dry_run keeps working too, in any run.

A run a trigger starts takes the mode of the run behind the event: a run_event trigger, the run that finished; an entity_event trigger, the run that wrote the entity, when it wrote it with its run token (the event's causationid is run/<id>). If that run is gone, the new run is a dry_run. Events nobody's run caused (a person, a webhook) start live runs. A run's origin (client, system, library, hub) says whose run it is; client runs are client.