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
- Templates and expressions
- Step reference
- Control flow:
condition,goto,parallel,map,workflow - Approvals
- Waiting for events
- Triggers
- Schedules
- Concurrency limits
- Starting and managing runs
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,conditionanditemsare 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, ormapwithmax_parallelabove 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.