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: [ ... ]

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

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).

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 }

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 }      # опц. значения по умолчанию