Workflows
🔵 Workflows are declarative multi-step pipelines on top of agents and
tools. Each workflow is a YAML file in config/workflows/ describing an
ordered list of steps that operate on an accumulating variable scope
(the payload input plus the output_key of each step).
Steps are hot-reloaded; execution is via POST /v1/workflows/<name>/run.
12.1 Step types
type |
Purpose |
|---|---|
agent |
run a template (or inline instructions) with a templated prompt; optionally a structured result via output_schema |
tool |
call a tool directly with templated args |
saga |
an ordered group of tools with compensations: if a step fails, the completed ones are rolled back in reverse order |
condition |
branch to a step id based on a templated boolean expression |
for_each |
iterate over a collection with a nested list of steps for each element |
loop |
repeat a nested block until until holds (or max_iterations) — the agent's inner loop |
verify |
check conditions (a tool and/or a judge agent) → { passed, checks }, without failing the step |
Templates are MiniJinja ({{ var }}, {% if %}), rendered from the variable scope;
{{ x | tojson }} embeds structured values, env_var('NAME')
reads an environment variable.
12.2 Example
name: triage
description: Просмотреть входящие коммиты и завести тикеты.
steps:
- id: fetch
type: tool
tool: git_commits
args: { since: "{{ input.since | default('yesterday') }}" }
output_key: commits
- id: process
type: for_each
over: commits.items # dot-path в скоуп переменных → массив
item: commit # переменная цикла
concurrency: 3
max_items: 100
steps:
- id: analyze
type: agent
template: review
prompt: "Отревьюй {{ commit.sha }}: {{ commit.message }}"
output_key: review
output_schema:
type: object
properties:
needs_action: { type: boolean }
summary: { type: string }
required: [needs_action, summary]
retry: { max: 2, backoff_secs: 5 }
timeout_secs: 120
- id: branch
type: condition
when: "{{ review.needs_action }}"
then: file_ticket
- id: file_ticket
type: tool
tool: send_report
args: { body: "{{ review.summary }}" }
collect:
reviews: "{{ results | tojson }}"
- id: done
type: tool
tool: send_report
args: { body: "{{ reviews }}" }
12.3 Step reference
agent
| Field | Description |
|---|---|
template |
the template to run (mutually exclusive with instructions) |
instructions |
an inline system prompt without a template |
tools |
additional tool names (merged with the template's) |
prompt |
the user message (MiniJinja) |
output_key |
where to save the result in the scope |
output_schema |
JSON Schema — the agent finishes via job_done; the validated payload becomes the value of output_key |
session |
stateful session key (steps with the same key in one run share context) |
retry |
{ max, backoff_secs } — retries of the whole step on error |
timeout_secs |
per-turn time limit |
budget |
token limit { max_per_turn, max_per_minute, max_total }; for inline instructions it replaces the default limit (4000 / 50000 / 200000); for template it is ignored |
next |
explicit id of the next step (see §12.9) |
Without output_schema, the step's output is the text of the agent's final answer. With it, the step
fails if the agent did not return a valid structured result.
tool
| Field | Description |
|---|---|
tool |
the name of a registered tool |
args |
arguments (string leaves are MiniJinja; a leaf that renders to JSON is passed as a structure) |
output_key |
where to save the result — the tool's data, if any, otherwise content |
idempotency_key |
idempotency key for side-effecting tools (see idempotency: in 05-tools-core) |
next |
explicit id of the next step |
condition
| Field | Description |
|---|---|
when |
MiniJinja expression → true/false |
then |
step id when true |
else |
step id when false |
for_each
| Field | Description |
|---|---|
over |
dot-path in the scope → array (e.g. commits.items) |
item |
the loop variable name (default item) |
concurrency |
maximum parallel iterations (default 1) |
max_items |
hard limit on elements |
steps |
a nested sub-workflow, run for each element |
collect |
a name → template map that folds per-item results (available as results) back into the parent scope |
next |
explicit id of the next step |
Each iteration works on a copy of the parent scope + item. Its outputs
become the per-item result in results (an array), which collect can
reformat.
saga
An ordered group of side-effecting tools with compensations: each
forward step can declare compensate, and if a step fails, the already
completed steps are rolled back in reverse order. Steps are persisted to a durable outbox, so
a stuck saga survives a restart.
The step's value is an array of the forward steps' results.
| Field | Description |
|---|---|
saga_id |
saga id (defaults to the step's id); the durable state key |
steps |
list of { tool, args, idempotency_key?, max_attempts?, compensate? } |
steps[].compensate |
{ tool, args } — the compensating call on rollback |
output_key |
where to put the array of results |
next |
explicit id of the next step |
steps:
- id: checkout
type: saga
saga_id: order-{{ input.order_id }}
output_key: checkout
steps:
- tool: reserve_stock
args: { sku: "{{ input.sku }}", qty: 1 }
compensate: { tool: release_stock, args: { sku: "{{ input.sku }}", qty: 1 } }
- tool: charge_card
args: { amount: "{{ input.amount }}", key: "{{ input.order_id }}" }
If charge_card fails, the core will call release_stock and mark the step
compensated; the saga step will fail with an error (saga '…' failed and was rolled back).
loop
Inner loop: repeats body until until renders to
true, but no more than max_iterations.
| Field | Description |
|---|---|
max_iterations |
hard iteration limit (required) |
body |
nested list of steps, executed on each iteration |
until |
MiniJinja expression → true/false; true stops the loop (success) |
on_max |
what to do when max_iterations is exhausted: fail (default) or success |
iteration |
the counter variable name (0-based), default iteration |
output_key |
where to put { iterations, stopped, max_iterations } |
next |
explicit id of the next step |
The outputs of body accumulate in the loop's scope and are carried into the parent scope
after completion (so until after each iteration sees the fresh
verification).
steps:
- id: fix
type: loop
max_iterations: 8
until: "{{ verification.passed }}"
on_max: fail
body:
- id: act
type: agent
template: bugfix-agent
prompt: "Исправь дефект (попытка {{ iteration }}): {{ input.summary }}"
output_key: attempt
- id: check
type: verify
tool: run_tests
args: { cmd: "cargo test" }
output_key: verification
verify
Checks one or more conditions. Unlike the other steps, a failed
check is not a step error: it returns { "passed": false, "checks": [...] },
so that you can branch on it (e.g. in a until of a loop).
| Field | Description |
|---|---|
checks |
list of checks: { type: tool, tool, args, expect } or { type: agent, template/instructions, prompt, output_schema } |
require |
all (default) or any |
output_key |
where to put the result { passed, checks } |
next |
explicit id of the next step |
For a tool check, expect describes how to read the result: without
expect, the tool's success is taken; otherwise — pointer (JSON Pointer into data,
otherwise the parsed content) plus equals/truthy. An agent check must
return a structured { "passed": bool }.
- id: v
type: verify
require: all
output_key: verification
checks:
- type: tool
tool: run_tests
args: { cmd: "cargo test" }
expect: { pointer: /passed, equals: true }
- type: agent
template: reviewer
prompt: "Проверь изменения: {{ diff }}"
output_schema:
type: object
properties: { passed: { type: boolean }, reason: { type: string } }
required: [passed]
12.4 Access control
A workflow can declare the templates it touches — then a scoped operator will only run it if those templates are in their scope:
name: reports
templates: [rss-agent, summarizer] # шаблоны, которые воркфлоу может трогать
steps: [ ... ]
Scope::All(full admin) can run any workflow.- A scoped operator (
templates: [...]inauth.admin_tokens) can run a workflow only if all of itstemplatesare in their scope, and only sees such workflows. - A workflow without
templatesis admin-only.
run / get_run return 403 for a scoped operator outside their scope; list
does not show them.
12.5 Triggers
A workflow can start without an explicit API call:
name: reports
triggers:
cron:
- schedule: "0 0 9 * * *" # cron: sec min hour dom month dow (UTC)
input: { topic: daily } # фиксированный input (по умолчанию {})
webhook:
token_env: WF_REPORTS_TOKEN # опциональный bearer (рекомендуется)
steps: [ ... ]
| Trigger | Description |
|---|---|
cron[].schedule |
cron expression (6 fields, second resolution) |
cron[].input |
fixed input per run |
webhook |
enables POST /v1/workflows/<name>/webhook; the JSON body becomes input |
webhook.token_env |
env with a bearer token; unset/empty → all requests are rejected |
12.6 HTTP API
| Endpoint | Auth | Description |
|---|---|---|
GET /v1/workflows |
admin | list of workflows + latest runs (?runs=N) |
POST /v1/workflows/<name>/run |
admin | run; the JSON body becomes input. Returns { ok, id } |
POST /v1/workflows/<name>/webhook |
its own token | run with webhook (JSON body → input) |
GET /v1/workflows/runs/<id> |
admin | run status + step history |
POST /v1/workflows/runs/<id>/cancel |
admin | stop the run at the next step boundary (status cancelled, the log is captured) |
GET /workflows |
admin | HTML page (run + view) |
Runs are written to logs/workflows.db (workflow_runs); status running →
success / error, with per-step records (id, status, output, error, retries,
timings). State is not persisted across restarts — only the run history.
12.7 Launching from agents and tools
A workflow can be launched from another agent via workflow_tool:
kind: workflow_tool
name: run_triage
description: Запустить воркфлоу триажа.
workflow: triage # дефолтный воркфлоу (перекрывается аргументом `workflow`)
Arguments: workflow (name, optional when configured) and input (object).
The tool returns a run id; the run continues asynchronously.
12.8 Local testing
agent-os module workflow run config/workflows/smoke.yaml '{"items":["Cargo.toml","README.md"]}'
# или input из файла (удобно на Windows):
agent-os module workflow run config/workflows/agent_demo.yaml --args-file args.json
tool-steps work with built-in + YAML tools from config/agents/;
agent-steps use the provider from config/server.yaml (or
DEEPSEEK_API_KEY). Run from the repository root so that config/ resolves.
12.9 Notes
- Control flow. Steps proceed in order;
conditionjumps to the idthen/else, and execution continues from there. A step withnextjumps to it instead of the next one in the list — usenextto close anifbranch and skipelse. Jumps are protected against infinite loops. for_eachover an empty array is a no-op (the step succeeds with[]).- A failed step fails the entire run;
retryapplies to the step before it. - Stateful sessions. Give two or more
agent-steps the samesessionkey — they will share a single conversation: the first creates the session (lazily), the following ones reuse it. The session is closed when the enclosing scope ends. Each iteration offor_eachgets its own session per key. output_schemaof stepagentreuses the same structured completion contract as agents (see §4): the schema replaces thejob_doneparameters, and the validated arguments become the result.
12.10 Durability (durable)
By default, run state lives only in memory: on restart, an unfinished
run is lost (only running remains in the history). The durable: true flag enables
a run journal — the engine commits scope and the position at each step boundary
(for loop — at each iteration), and on server startup, unfinished runs are
restarted from the last boundary (logs/workflows.db, table
workflow_run_state).
name: long-harness
durable: true
budget: # опциональный бюджет на весь запуск
max_per_turn: 8000
max_per_minute: 60000
max_total: 500000
steps: [ ... ]
The semantics are at-least-once (like durable sessions and outbox): an unfinished step
or iteration is replayed in full, so step side effects must be
idempotent (the idempotency_key field of a tool-step or saga). Nested steps
inside loop are replayed from the start of the current iteration, while for_each — only from
unfinished elements (completed elements are taken from the journal and are not
replayed).
budgetsets the limit of the shared groupwf:<run_id>used by all agent steps of the run; its spent total is included in the journal, so after a restart the limit is not reset. Adurablerun always gets such a group always (without a limit, ifbudgetis not set) — for cost (token) accounting.- Idempotency. For a
durablerun, atool-step without an explicitidempotency_keygets a stable keywf:<run_id>:<шаг>:<итерация>: when a boundary is replayed, the repeated side effect is deduplicated by the side-effect store (requireslogging.side_effect_db_path/outbox — otherwise dedup lives only in memory). An explicitidempotency_keytakes priority. - Run deadline.
timeout_secslimits the time of the entire run: at step boundary, the run stops with statustimeout. The absolute deadline is restored from the start time in the journal, so a restart does not extend the limit. - A run that exceeds the recovery attempt limit (3) is marked
error("resume limit exceeded") and is no longer replayed — this is quarantine for a "poison" run. - Requires
logging.workflow_db_pathto be enabled (by defaultlogs/workflows.db).
12.11 Harness (isolated workspace)
The harness: block turns a workflow into an "internal harness" — a durable run with
a loop (loop/verify) in an isolated working directory. Example: fixing a
defect from a ticket.
name: jira-bugfix
durable: true
harness:
workspace:
kind: git_worktree # или dir
repo: "."
base: origin/main
cleanup: on_success # on_success (по умолчанию) | always | never
triggers:
webhook: { token_env: JIRA_HOOK_TOKEN }
steps:
- id: fix
type: loop
max_iterations: 8
until: "{{ verification.passed }}"
body:
- { id: act, type: agent, template: bugfix-agent,
prompt: "Исправь: {{ input.summary }}", session: fix }
- { id: check, type: verify, tool: run_tests,
args: { cmd: "cargo test" }, output_key: verification }
- At startup, the engine prepares the workspace and puts a variable
workspace({ path, kind, branch, repo }) into scope — steps address it as{{ workspace.path }}. git_worktree: a separate branchharness/<run_id>frombase; the agent's edits are isolated and visible as a diff, the branch remains after cleanup as an artifact.cleanup:on_successdeletes on success,always— always,never— preserves for analysis.- The workspace is tied to the durable journal: after a restart, the run reconnects
to the same directory/worktree (
prepareis idempotent). - Workspace tools. The working directory path is passed to agent steps as
template_vars[code_root], so the tools of thecodemodule (read_file,write_file,edit_file,list_files,grep,run_command) work inside the isolated workspace, not in the server's cwd. - Artifacts: before cleanup, the engine collects
git status,git diff --stat, andgit difffrom the workspace (for a git worktree) and puts them into the read-model;/harnessshows the run's diff. - Input/output artifacts. In the
harness:block you can declaratively declaresources(rendered at startup frominput+workspace),results(at finish — also fromoutput_keyof the steps), andmaterials(inline content). An artifact has{ kind, title, url?, content? }; an empty render (url/title/contentare empty) is discarded. The UI (/harness) renders three blocks — Sources / Results / Materials — generically:url→ link, otherwisetitlecopyable,content→ collapsible block. Captured diff/status from the provisioner land inmaterialsautomatically. - Run notifications. In the harness definition (
config/harnesses/<id>.yaml), thenotify:block sends event messages to any channels (universally — the same sinks as cron):eventsselects WHAT to send (started|finished|failed, by default[finished, failed]), andsinks— WHERE (formatCronSink:yandex_messenger,telegram,vk,email,webhook,channel,push, …). Delivery goes through the shared outbound fan-out; the message is assembled from the run's artifacts (source task, status, tokens, MR link). - Observability: the
/harnesspage and theGET /v1/harness/runsAPI,GET /v1/harness/runs/:id(moduleharness,logs/harness.db). The run detail shows a timeline of steps/iterations (from workflow events), cost (tokens), iteration count, workspace, and artifacts. - Summary:
GET /v1/harness/summary?window_secs=— success/failure rate, average iterations/duration, total tokens, and work item queue depth; displayed on the page as a strip of cards. - Metrics at
GET /metrics:harness_runs_<status>_total,harness_run_duration_ms,harness_run_iterations,harness_run_tokens_total,harness_work_items_<status>(queue depth).
12.12 Work items
A work item is a normalized unit of harness work (ticket, payment, incident).
Deduplicated by (source, external_id); lifecycle:
new → claimed → running → done | failed | escalated.
Sources (Jira/GitHub/email/webhook) send an ingest — re-ingesting the same
(source, external_id) updates the content but does not reset the status:
POST /v1/harness/work-items
{ "source": "jira", "external_id": "PROJ-123",
"title": "Fix login", "body": "...", "priority": 2, "metadata": { } }
API (module harness, table harness_work_items):
| Method | Purpose |
|---|---|
GET /v1/harness/work-items?status=&source= |
list (filters, fresh/priority on top) |
GET /v1/harness/work-items/:id |
a single item |
POST .../:id/claim |
claim for work (lease; a second claim loses) |
POST .../:id/status |
manual status change by an operator |
POST .../:id/run {workflow, input?} |
claim + start the harness workflow; the run is linked to the item |
When the linked run completes, the item's status is updated automatically:
success → done, cancelled → new (return to queue), otherwise failed.
12.13 Harness registry
The set of harnesses is defined by a registry — config/harnesses/<id>.yaml files.
A harness is a domain-agnostic internal loop (loop/verify), not necessarily
about code; workspace is optional. The registry links a human-readable description to the
executor workflow and the work source.
id: jira-bugfix
title: Bugfix из тикета
description: |
Забирает дефект, воспроизводит, чинит в изолированном workspace,
прогоняет тесты и отдаёт на ревью.
workflow: jira-bugfix # workflow с блоком harness:
enabled: true
source:
kind: jira # какой источник кормит (* = любой)
auto_run: true # новый work item запускает харнесс сам
filter: { project: PROJ } # опц. равенство по metadata work item
defaults: { priority: 1 } # опц. значения по умолчанию
GET /v1/harness/harnesses— list of definitions (visible at/harness).- Routing: on work item ingest, the first enabled
harness whose
source.kind(andfilter) matches is selected; withauto_run: truethe run starts on its own, otherwise — viaPOST /v1/harness/work-items/:id/run. - Loaded at startup from
config/harnesses/(a directory next toconfig/workflows/). - Operator tools for the engineer (agent
admin, viatool_call):harness_list,harness_toggle,harness_summary,harness_runs,harness_work_items,harness_ingest,harness_run_item— inconfig/agents/toolbox/tools/.