# Definitions

Workflow definitions are YAML files that declare inputs, policies, steps, and optional notifications for one durable automation flow.

## Where Definitions Come From

You can run a workflow by:

- passing a filesystem path to a YAML file
- using a definition from `examples/workflows/`
- using a packaged definition shipped with Anteroom

Today, the reference example lives in `examples/workflows/issue_delivery.yaml`.

## Minimal Definition

```yaml
kind: workflow
id: hello_workflow
version: 0.1.0

inputs: {}

steps:
  - id: hello
    type: runner
    runner: shell
    command: "echo hello"
    timeout: 10
```

The engine requires:

- `kind: workflow`
- `id`
- `version`
- at least one step

## Top-Level Fields

| Field | Required | Description |
|---|---|---|
| `kind` | Yes | Must be `workflow` |
| `id` | Yes | Workflow identifier |
| `version` | Yes | Workflow definition version |
| `inputs` | No | Declared input schema |
| `params` | No | Overridable parameter defaults (#958) |
| `policies` | No | Workflow-wide policy values |
| `credentials` | — | *Declared in `config.yaml`, not here.* See [Credentials](#credentials) |
| `steps` | Yes | Ordered list of step definitions |
| `notifications` | No | Optional hook configuration |

## Inputs

Declare inputs so the engine can validate required values before execution.

```yaml
inputs:
  issue_number:
    type: integer
    required: true
  environment:
    type: string
    required: false
```

The current engine enforces required presence. It does not yet provide a rich type system beyond workflow-authored conventions.

## Params

Params declare overridable defaults so teams can reuse the same workflow with different commands, prompts, or policies without forking the YAML (#958).

```yaml
params:
  lint_command:
    default: "ruff check src/"
    description: "Lint command to run"
  test_command:
    default: "python -m pytest tests/unit/ -x -q"
    description: "Test command to run"
```

Params are resolved at execution time from (in precedence order):

1. CLI `--param key=value` flags or API `params` payload (highest)
2. Definition `default` values (lowest)

Params without a `default` key are required — they must be supplied at runtime.

Param keys and input keys must not overlap. The engine rejects definitions where any `params` key matches an `inputs` key.

Template resolution receives a single flat dict of `{**resolved_params, **inputs}`. Use `{param_name}` placeholders in `command`, `prompt`, `argv`, and `destination` fields just like input variables.

Resolved params are persisted in the run record for resume fidelity.

## Credentials

Named credentials are declared in **`config.yaml`** under `workflow.credentials` — NOT in the workflow definition itself. This separation ensures secret-binding configuration stays in the operator's config, not in shareable workflow YAML.

```yaml
# In config.yaml (operator-controlled)
workflow:
  credentials:
    - name: github_token
      env_var: GITHUB_TOKEN
    - name: deploy_key
      command: "op read op://vault/deploy_key/credential"
```

Workflow steps reference credentials by name using the `credentials` field (see [Credential Binding](#credential-binding-in-steps) below). At execution time, the engine resolves the named credential from the config. Resolved values exist only in memory — never logged or stored.

See [Configuration — workflow.credentials](../configuration/config-file.md) for the full config reference.

## Policies

### policies.budget

Cap wall-clock time, step count, or token consumption for a run.

| Field | Type | Default | Description |
|---|---|---|---|
| `max_duration_seconds` | int | 0 | Wall-clock ceiling in seconds (0 = unlimited) |
| `max_steps` | int | 0 | Max completed steps per run (0 = unlimited) |
| `max_tokens` | int | 0 | Max total tokens consumed per run (0 = unlimited) |

```yaml
policies:
  budget:
    max_steps: 50
    max_tokens: 100000
    max_duration_seconds: 3600
```

The effective budget is the minimum of non-zero values from the definition and the config-level `workflow.budget` ceiling.

## Step Types

### Runner Steps

Runner steps execute work through one of the supported runner types.

#### Shell Runner

```yaml
- id: fast_checks
  type: runner
  runner: shell
  command: "ruff check src tests"
  timeout: 300
```

Use `shell` when you want shell parsing and shell syntax.

#### Python Script Runner

```yaml
- id: summarize_report
  type: runner
  runner: python_script
  command: "scripts/build_report.py"
  argv:
    - "{issue_number}"
    - "--format"
    - "json"
  timeout: 300
```

`python_script` executes the given script path through the current Python interpreter.

#### Agent Runner

```yaml
- id: draft_summary
  type: runner
  runner: cli_codex
  prompt: |
    Review the prior artifacts and produce a concise summary.
  context_from:
    - step: fetch_data
      field: result_summary
  tools:
    - read_file
    - bash
  timeout: 300
```

Current agent runners:

- `cli_claude`
- `cli_codex`

### Template Interpolation

The `command`, `argv`, and `prompt` fields support `{variable}` placeholders that are resolved from workflow `inputs` at execution time.

```yaml
inputs:
  project_name:
    type: string
    required: true

steps:
  - id: greet
    type: runner
    runner: shell
    command: "echo Building {project_name}"
```

Rules:

- Variables must reference declared `inputs` or `params` — undefined variables raise an error
- For **shell runners**, values are passed through `shlex.quote()` before interpolation to prevent shell injection
- For **python_script runners** and **agent prompts**, values are interpolated as plain strings (no shell quoting needed)

### Gate Steps

Gate steps evaluate a named condition.

```yaml
- id: gate_issue_current
  type: gate
  condition: issue_is_current
  if_false: blocked_issue_not_current
```

Fields:

| Field | Required | Description |
|---|---|---|
| `id` | Yes | Step identifier |
| `type` | Yes | Must be `gate` |
| `condition` | Yes | Name of registered gate function |
| `if_false` | No | Stop reason / summary when blocked |

### Human Gate Steps

Human gate steps pause the workflow and present a decision to an operator. The run enters `waiting_for_input` status until the operator responds via `aroom workflow respond`.

```yaml
- id: review_decision
  type: human_gate
  prompt: "Review the generated report. Approve to continue or reject to stop."
  options:
    - id: approve
      label: Approve and continue
    - id: reject
      label: Reject and stop
```

Fields:

| Field | Required | Description |
|---|---|---|
| `id` | Yes | Step identifier |
| `type` | Yes | Must be `human_gate` |
| `prompt` | Yes | Message displayed to the operator |
| `options` | Yes | List of decision options, each with `id` and `label` |

The operator responds with:

```bash
aroom workflow respond <run_id> --option approve
aroom workflow resume <run_id>
```

### Loop Steps

Loop steps repeat nested steps up to a bounded round count.

```yaml
- id: review_loop
  type: loop
  max_rounds: 2
  steps:
    - id: review
      type: runner
      runner: cli_codex
      prompt: |
        Review the current changes and report findings.
    - id: fix_review_findings
      type: runner
      runner: cli_claude
      prompt: |
        Address the findings from the prior review step.
      context_from:
        - step: review
          field: result_findings
```

Nested steps are persisted with per-round identifiers so they appear in history and monitoring.

#### Loop Success Conditions (#1125)

Use `until` to declare when a loop has succeeded. The condition uses the same shape as `when` guards and references a nested step:

```yaml
- id: repair_loop
  type: loop
  max_rounds: 5
  until:
    step: run_tests
    field: result_status
    equals: success
  fail_if_not_met: true
  steps:
    - id: fix_code
      type: runner
      runner: cli_claude
      prompt: "Fix the failing tests"
    - id: run_tests
      type: runner
      runner: shell
      command: pytest
```

Fields:

| Field | Type | Default | Description |
|---|---|---|---|
| `until` | mapping | `null` | Condition evaluated after each round. Same shape as `when`: `step`, `field`, and one operator (`equals`, `not_equals`, `contains`, `is_not_empty`, `is_empty`). Must reference a nested step. |
| `fail_if_not_met` | boolean | `false` | When `true`, the loop fails if `until` is never satisfied after `max_rounds`. Requires `until`. |
| `stop_after_unchanged` | integer | `null` | Stop the loop early when the same failure signature repeats for this many consecutive rounds. Must be `>= 2`. Uses structured failure triage when available, falls back to summary-based hashing. |

#### Early Loop Termination (#1130)

Use `break_when` to stop looping on a terminal condition different from success (e.g., "no changes needed"):

```yaml
- id: repair_loop
  type: loop
  max_rounds: 5
  until:
    step: run_tests
    field: result_status
    equals: success
  break_when:
    step: fix_code
    field: result_summary
    contains: "no changes needed"
  fail_if_not_met: true
  steps:
    - id: fix_code
      type: runner
      runner: cli_claude
      prompt: "Fix the failing tests"
    - id: run_tests
      type: runner
      runner: shell
      command: pytest
```

| Field | Type | Default | Description |
|---|---|---|---|
| `break_when` | mapping | `null` | Condition evaluated after each round. Same shape as `until`. Must reference a nested step. Non-failure early exit — the loop succeeds. |

#### Loop Termination Priority

After each round completes, the engine evaluates in this order:

| Priority | Condition | Outcome | `loop_end_reason` |
|---|---|---|---|
| 1 | Exception during round | Loop fails immediately | (step failure) |
| 2 | `break_when` matched | Loop succeeds | `break_when` |
| 3 | `until` matched | Loop succeeds | `until_met` |
| 3.5 | `stop_after_unchanged` threshold reached | Loop fails | `no_progress` |
| 4 | All nested steps succeeded (no `until` set) | Loop succeeds | `all_succeeded` |
| 5 | More rounds available | Continue to next round | — |
| 6 | Rounds exhausted, `fail_if_not_met: true` | Loop fails | `until_not_met` |
| 7 | Rounds exhausted, `fail_if_not_met: false` | Loop succeeds | `exhausted` |

The `loop_end_reason` is stored in the loop step's `result_artifacts` and appears in monitoring, diagnosis, and CLI output.

### Parallel Steps

Parallel steps execute named branches concurrently and wait for all to complete (#976).

```yaml
- id: fetch_all
  type: parallel
  join: all
  branches:
    - id: fetch_api
      steps:
        - id: call_api
          type: runner
          runner: shell
          command: "curl -o api.json https://internal/data"
    - id: fetch_db
      steps:
        - id: query_db
          type: runner
          runner: shell
          command: "psql -c 'SELECT ...' > db.csv"
```

Fields:

| Field | Required | Description |
|---|---|---|
| `id` | Yes | Step identifier |
| `type` | Yes | Must be `parallel` |
| `join` | No | Join strategy (only `all` in V1, default `all`) |
| `branches` | Yes | List of branch definitions, each with `id` and `steps` |

Rules:

- Branch steps use **qualified IDs** (`branch_id/step_id`) for durable identity
- `/` is reserved — step IDs and branch IDs must not contain `/`
- Nested parallel steps are not allowed (depth limit = 1)
- `human_gate` steps inside branches are not allowed
- `context_from` inside a branch can reference prior top-level steps and prior steps in the **same** branch, but **not** sibling branches (which are concurrent)
- Steps after the parallel step can reference any branch step via qualified IDs: `context_from: [{step: fetch_api/call_api, field: result_summary}]`
- On any branch failure, remaining branches are cancelled (fail-fast)
- Resume preserves completed branch steps and only re-runs incomplete branches

### Compensation Steps

Compensation blocks declare rollback behavior for steps with side effects (#978).

```yaml
- id: deploy
  type: runner
  runner: shell
  command: "deploy.sh --env staging"
  compensate:
    type: runner
    runner: shell
    command: "rollback.sh --env staging"

- id: validate
  type: runner
  runner: shell
  command: "smoke-test.sh"
```

If `validate` fails, the engine runs `deploy`'s compensate block to roll back the deployment.

Rules:

- `compensate` type must be `runner` or `llm` (no gates, loops, or parallel)
- Compensation steps cannot themselves have `compensate` blocks (no nesting)
- Compensation executes in reverse chronological order of completed steps
- Only steps that actually completed with a `compensate` block are rolled back
- Compensation is **best-effort** — if a compensate step fails, the engine continues with remaining compensations
- Run status transitions: `compensating` (active) → `compensated` (all rollback succeeded) or `compensation_failed` (some rollback failed)
- If no completed steps have `compensate` blocks, the run goes straight to `failed`
- Budget limits do not block compensation steps (cleanup takes priority)
- Compensation state is checkpointed — interrupted compensation can resume

### Failure Branches (#1127)

Use `on_failure` to declare steps that run when a step fails. The branch executes after retries are exhausted:

```yaml
- id: run_tests
  type: runner
  runner: shell
  command: pytest
  on_failure:
    - id: collect_logs
      type: runner
      runner: shell
      command: cat test-output.log
    - id: notify
      type: runner
      runner: shell
      command: "curl -X POST https://hooks.example.com/notify"
- id: deploy
  type: runner
  runner: shell
  command: ./deploy.sh
```

When `run_tests` fails, the `on_failure` branch runs. If all branch steps succeed, the workflow continues to `deploy`. If any branch step fails, the run aborts.

| Field | Type | Description |
|---|---|---|
| `on_failure` | list of step defs | Steps to run when the parent fails. Only `runner` and `llm` types allowed. |

Rules:

- Branch step IDs are prefixed with `{parent_step_id}/_fail/` for observability
- Nested `on_failure` is not allowed (no recursion)
- Only `runner` and `llm` steps allowed in branches (no gates, loops, or parallel)
- `on_failure` is not allowed on `gate` or `human_gate` steps
- **Interaction with `retry`**: retry first, `on_failure` after exhaustion
- **Interaction with `continue_on`**: `on_failure` runs first; if branch succeeds, `continue_on` behavior applies
- Failed step is added to `step_results` so branch steps can reference it via `context_from`

Events emitted: `failure_branch_started`, `failure_branch_completed`, `failure_branch_failed`.

### Semantic Retries (#1129)

Use `retry_on_result` inside the `retry` block to retry based on step result content, not just exceptions:

```yaml
- id: generate
  type: runner
  runner: cli_claude
  prompt: "Generate the feature code"
  retry:
    max_attempts: 3
    backoff: exponential
    initial_delay: 5
    retry_on_result:
      - field: result_artifacts.exit_code
        not_equals: "0"
```

Each condition uses the same shape as `when` guards: `field` plus one operator (`equals`, `not_equals`, `contains`, `is_not_empty`, `is_empty`). Conditions are evaluated against the step's result after success — if any match, the step is retried.

The `step_retry` event payload includes `retry_reason` to distinguish retry triggers:

| `retry_reason` | Trigger |
|---|---|
| `exception` | Step raised an exception |
| `failed_status` | Step returned `result_status: failed` |
| `result_condition` | A `retry_on_result` condition matched |

**Interaction priority**: retry (including `retry_on_result`) → `on_failure` branch → `continue_on` → abort.

## Runner Fields

Not every field applies to every runner.

| Field | Applies to | Description |
|---|---|---|
| `runner` | all runner steps | Runner type |
| `command` | `shell`, `python_script` | Shell command string or script path |
| `argv` | `python_script` | Positional arguments |
| `prompt` | agent runners | User prompt (or resolved from skill) |
| `system_prompt` | agent runners | Optional system override |
| `skill_name` | agent runners | Skill name to expand as prompt (#957) |
| `skill_args` | agent runners | Arguments for skill expansion (#957) |
| `context_from` | agent runners | Pull data from earlier step results or sources (#957) |
| `outputs` | all steps | Declare named outputs extracted from step results (#1131) |
| `inject_rules` | agent runners | Inject RULE/INSTRUCTION artifacts into system_prompt (#957) |
| `inject_conventions` | agent runners | Inject ANTEROOM.md into system_prompt (#957) |
| `tools` | agent runners | Optional tool filter |
| `credentials` | agent/shell runners | Credential bindings: `[{"name": "KEY", "from": "cred_name"}]` (#970) |
| `env` | opaque runners | Extra environment variables |
| `working_dir` | opaque runners | Working directory override |
| `timeout` | all runner steps | Per-step timeout in seconds |
| `approval_mode` | reserved policy field | Cannot be more permissive than effective safety config |

## `context_from`

`context_from` lets a later step consume normalized output from earlier steps or Anteroom sources (#957).

### Prior Step References

```yaml
context_from:
  - step: fast_checks
    field: result_summary
  - step: review
    field: result_findings
```

Rules:

- references must point to earlier steps
- both `step` and `field` are required
- invalid references are rejected at definition load time

The field path is dotted and resolves against normalized step result data. Examples:

- `result_summary`
- `result_artifacts.exit_code`
- `result_findings`

### Source References (#957)

```yaml
context_from:
  - source_id: "550e8400-e29b-41d4-a716-446655440000"
  - source_group_id: "660e8400-e29b-41d4-a716-446655440001"
```

You can inject Anteroom sources directly:

- `source_id`: UUID of a specific source; content wrapped as untrusted
- `source_group_id`: all sources in a group; concatenated and each wrapped as untrusted

Requires `db` connection at execution time.

## Structured Outputs (#1131)

Steps can declare named outputs extracted from step results, giving downstream steps clean access to specific values without parsing summaries or raw artifacts.

### Declaration

```yaml
- id: run_tests
  type: runner
  runner: shell
  command: pytest --json-report
  outputs:
    - name: exit_code
      from: result_artifacts.exit_code
    - name: report_path
      from: result_artifacts.raw_output_path
```

Each output entry has:

| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Output name (must be unique within the step) |
| `from` | Yes | Dotted path resolved against step result data |

The `from` path resolves against the same data available to `context_from` and `when`:

- `result_status`
- `result_summary`
- `result_artifacts.exit_code`
- `result_artifacts.structured_output.key`
- `result_findings`

### Runner-populated outputs

Runners can also populate outputs directly. If a runner returns an output with the same name as a declared output, the runner value takes precedence over the `from` path resolution.

### Using outputs in `when` clauses

```yaml
- id: deploy
  type: runner
  runner: shell
  command: ./deploy.sh
  when:
    step: run_tests
    field: result_outputs.exit_code
    equals: "0"
```

### Using outputs in `context_from`

```yaml
- id: notify
  type: runner
  runner: cli_claude
  prompt: "Summarize the report"
  context_from:
    - step: run_tests
      field: result_outputs.report_path
```

### Persistence

Resolved outputs are stored in the `result_outputs_json` column of `workflow_steps` and survive checkpoint/resume. They appear in:

- **CLI**: `aroom workflow history` detail table (Outputs column)
- **Web API**: `result_outputs` field in step response objects
- **Timeline**: step line suffix shows output count (e.g., `[2 outputs]`)

## Credential Binding in Steps

Steps can bind named credentials from the workflow's `credentials` section as environment variables (#970).

```yaml
steps:
  - id: deploy
    type: runner
    runner: shell
    command: "echo $DEPLOY_KEY | deploy.sh"
    credentials:
      - name: DEPLOY_KEY
        from: deploy_key
```

Rules:

- `credentials` is a list of bindings
- Each binding has `name` (env var to create) and `from` (credential name from top-level config)
- Credential resolution happens at step runtime
- On resolution failure, the step is blocked with a redacted error
- Resolved values exist only in the subprocess environment, never logged

Agent runners can also use `credentials` to make secrets available to tool execution.

## Artifact Injection in Agent Steps

Agent steps can leverage Anteroom's artifact system to inject context and behavioral rules (#957).

```yaml
steps:
  - id: review_code
    type: runner
    runner: cli_claude
    prompt: "Review the attached code for best practices."
    skill_name: code_review_helper
    skill_args: "language=python framework=django"
    inject_rules: true
    inject_conventions: true
    context_from:
      - source_id: "550e8400-e29b-41d4-a716-446655440000"
```

Fields:

- **`skill_name`** — Name of a skill artifact to expand as the prompt. If provided, overrides `prompt`
- **`skill_args`** — Arguments passed to skill expansion (optional)
- **`inject_rules`** — If true, injects all RULE/INSTRUCTION artifacts into the system prompt
- **`inject_conventions`** — If true, injects the global ANTEROOM.md into the system prompt
- **`context_from`** — Can now reference sources via `source_id` or `source_group_id`

Rules:

- `skill_name` requires an agent runner; other artifact injection fields are optional
- If neither `prompt` nor `skill_name` is provided, validation fails
- Artifact injection happens at step runtime within the agent's context
- Conventions are appended to the system prompt

## Notifications

Workflows can define best-effort notification hooks.

```yaml
notifications:
  hooks:
    - transport: webhook
      url: https://events.example.com/workflows
      events:
        - run_completed
        - run_failed
    - transport: unix_socket
      path: /tmp/workflow-events.sock
      events:
        - all
```

Supported transports:

- `webhook`
- `unix_socket`

Webhook rules:

- URLs are validated against Anteroom's egress allowlist before execution starts
- `ai.allowed_domains` and `ai.block_localhost_api` apply
- invalid or blocked URLs fail closed at run startup

Delivery rules:

- hook failures are logged
- hook failures do not fail the workflow
- pending hook tasks are drained with a bounded timeout before process exit

## Validation Rules

The current loader validates at least:

- `kind`, `id`, and `version`
- at least one step exists
- runner steps have a valid payload
- gate steps provide `condition`
- `context_from` references only earlier steps
- approval mode cannot exceed effective safety policy

Runtime validation also checks:

- required inputs are present
- target lock can be acquired
- working directory exists for opaque runners
- hook config passes egress validation before the run starts

## Example: Research-Style Workflow

```yaml
kind: workflow
id: daily_briefing
version: 0.1.0

inputs:
  topic:
    type: string
    required: true

steps:
  - id: collect_sources
    type: runner
    runner: shell
    command: "python scripts/fetch_sources.py --topic {topic}"

  - id: summarize_sources
    type: runner
    runner: cli_codex
    prompt: |
      Summarize the collected sources for {topic}.
    context_from:
      - step: collect_sources
        field: result_summary

  - id: gate_summary_quality
    type: gate
    condition: always_pass
    if_false: blocked_summary_quality
```

This is why the engine should stay generic even if the first example happens to be delivery-focused.

## Conditional Execution (`when`)

Any step can include a `when` clause that evaluates a prior step's result. If the condition is not met, the step is skipped with a `step_skipped` event.

```yaml
steps:
  - id: lint
    type: runner
    runner: shell
    command: "ruff check src/"

  - id: publish_lint_report
    type: publish
    when:
      step: lint
      field: result_status
      equals: "success"
    destination:
      adapter: file
      path: "/tmp/lint-report.txt"
```

Fields:

| Field | Required | Description |
|---|---|---|
| `step` | Yes | ID of a prior step to evaluate |
| `field` | Yes | Dot-path into the step's result data |
| `equals` | Yes | Expected value; skip if not matched |

## Retry Configuration

Runner steps can declare retry behavior for transient failures.

```yaml
steps:
  - id: flaky_deploy
    type: runner
    runner: shell
    command: "deploy.sh"
    retry:
      max_attempts: 3
      backoff: exponential
      initial_delay: 5
      max_delay: 60
```

Fields:

| Field | Type | Default | Description |
|---|---|---|---|
| `max_attempts` | int | 1 | Total attempts (1 = no retry) |
| `backoff` | string | `exponential` | Backoff strategy: `exponential` or `fixed` |
| `initial_delay` | int | 5 | Seconds before first retry |
| `max_delay` | int | 60 | Maximum backoff delay in seconds |

On each retry, a `step_retry` event is emitted with attempt number, max attempts, backoff delay, and error message.

## Failure Continuation (`continue_on`)

Any step type except `gate` and `human_gate` can opt into `continue_on: [failed]` so a failed result does not abort the run. The failed step is still recorded as failed — the result is not rewritten to success.

```yaml
steps:
  - id: lint_check
    type: runner
    runner: shell
    command: ruff check .
    continue_on: [failed]    # workflow continues even if lint fails

  - id: deploy
    type: runner
    runner: shell
    command: ./deploy.sh
    when:
      step: lint_check
      field: result_status
      equals: success         # skip deploy if lint failed
```

| Field | Type | Default | Description |
|---|---|---|---|
| `continue_on` | list | `null` | List of result statuses that allow continuation. Only `["failed"]` is accepted |

Rules:

- Not allowed on `gate` or `human_gate` steps (those have fundamentally different semantics)
- The failed step is added to `step_results` — downstream `when` and `context_from` can reference it
- A `step_continued` event is emitted (distinct from `step_failed`, which means the run aborted)
- If `retry` is also configured, retry is attempted first; `continue_on` kicks in only after all retries are exhausted
- A run that continues past a failed step and completes all remaining steps finishes as `completed`
- Only `"failed"` is accepted. The `blocked` status has different semantics (approval/gate pause) and is out of scope

## Failed Step Context (`context_from_failed_step`)

Repair steps can consume a failed step's result directly via `context_from_failed_step`. This injects the referenced step's `result_status`, `result_summary`, `result_artifacts`, and `result_findings` as a structured XML context block.

```yaml
steps:
  - id: run_tests
    type: runner
    runner: shell
    command: pytest
    continue_on: [failed]

  - id: repair
    type: runner
    runner: cli_claude
    prompt: "Fix the failing tests"
    context_from_failed_step: run_tests
    # Injects structured context block with all result fields
```

| Field | Type | Default | Description |
|---|---|---|---|
| `context_from_failed_step` | string | `null` | Step ID to pull failure context from |

Rules:

- Valid on `runner`, `llm`, `human_gate`, and `publish` steps
- Must reference a step declared before the current step in execution order
- If the referenced step did not fail, context is still included with a note
- If the referenced step is not in `step_results` (e.g., it was skipped), context is omitted silently
- Coexists with `context_from` — both are resolved and concatenated into the prompt/input
- The injected context uses an XML envelope: `<failed_step_context step="...">` with nested `<status>`, `<summary>`, `<artifacts>`, `<findings>`, and `<failure_triage>` elements
- When the failed step's output contains parseable failure signals (pytest FAILED lines, traceback frames, ruff errors), a `<failure_triage>` element is included with normalized fields: `exit_code`, `first_failing_test`, `first_assertion`, `first_traceback_file`, `first_traceback_line`, `failure_count`, and `failure_signature`

## Idempotency Keys

Publish steps support an `idempotency_key` field that generates a unique key per execution. Downstream adapters can use this key to detect and deduplicate retried deliveries.

```yaml
steps:
  - id: publish_report
    type: publish
    idempotency_key: "{run_id}:{step_id}"
    destination:
      adapter: webhook
      url: "https://hooks.example.com/workflow"
```

| Field | Type | Default | Description |
|---|---|---|---|
| `idempotency_key` | string | `"{run_id}:{step_id}"` | Template for the key. Supports `{run_id}`, `{step_id}`, and any declared `inputs` variable |

Rules:

- Engine-owned variables (`run_id`, `step_id`) override user inputs with the same name
- The resolved key is persisted on the step record before execution starts (crash-safe)
- The key appears in `workflow history` step details and in checkpoint data

## LLM Steps

LLM steps run a bounded, non-streaming completion against the configured AI provider. They are lighter than agent runner steps — no tools, no agentic loop, just a single prompt/response round-trip with usage tracking.

```yaml
steps:
  - id: summarize
    type: llm
    prompt: "Summarize the following report in 3 bullet points:\n{report_text}"
    model: gpt-4o-mini
    temperature: 0.3
    max_tokens: 1024
```

### LLM Step Fields

| Field | Required | Description |
|---|---|---|
| `prompt` | Yes | User prompt. Supports `{variable}` template interpolation from inputs |
| `system_prompt` | No | System prompt prepended as a system message |
| `model` | No | Per-step model override. Falls back to the default AI model if omitted |
| `temperature` | No | Sampling temperature override |
| `max_tokens` | No | Max completion tokens (default 4096) |
| `context_from` | No | Inject prior step output or source content into the prompt |
| `response_format` | No | Set to `json_object` for structured JSON output. Parsed result stored in `result_artifacts.structured_output` |
| `when` | No | Conditional execution (same syntax as other step types) |

### Per-Step Model Override

When `model` is specified and differs from the default, the engine creates a temporary AI service instance with the overridden model. This lets you use cheaper models for simple tasks and more capable models for complex ones within the same workflow.

```yaml
steps:
  - id: classify
    type: llm
    prompt: "Classify this issue: {description}"
    model: gpt-4o-mini
    temperature: 0

  - id: deep_analysis
    type: llm
    prompt: "Provide detailed analysis of: {description}"
    model: gpt-4o
    context_from:
      - step: classify
        field: result_summary
```

### Fallback Models

LLM steps support automatic fallback to alternative models when the primary model fails (#986). Fallback chains allow you to gracefully degrade service quality instead of failing the entire step.

```yaml
steps:
  - id: analyze
    type: llm
    prompt: "Analyze this data: {data}"
    model: gpt-4o
    fallback:
      - model: gpt-4-turbo
      - model: gpt-3.5-turbo
        temperature: 0.5
```

When the primary `model` fails (e.g., due to rate limiting, provider outage, or context limit exceeded), the engine automatically tries the next model in the `fallback` list. Each entry requires at least a `model` key and can optionally override `temperature` and `max_tokens`. If all models are exhausted, the step fails.

An `llm_fallback` event is emitted each time a fallback occurs:

```json
{
  "event_type": "llm_fallback",
  "payload": {
    "step_id": "summarize",
    "failed_model": "gpt-4o",
    "error": "rate limit exceeded",
    "next_index": 1,
    "remaining": 1
  }
}
```

**Rules:**

- `fallback` is optional and defaults to empty (no fallback)
- All fallback models must be on the same provider as the primary model
- Fallback is transparent to the step result — `result_artifacts.model` records the model that succeeded
- `result_artifacts.primary_model` records the originally configured model
- `result_artifacts.fallback_used` (bool) and `fallback_index` (int) indicate whether and which fallback was used
- Retries and fallback are independent — each retry attempt runs the full fallback chain
- Budget limits (tokens, duration) still apply to fallback attempts

### Response Caching

LLM steps can opt into response caching to avoid repeated API calls for identical prompts:

```yaml
steps:
  - id: summarize
    type: llm
    prompt: "Summarize: {data}"
    model: gpt-4o
    cache:
      enabled: true
      ttl: 3600
```

When caching is enabled, the engine checks for a cached response before each API call. The cache key is a SHA-256 hash of the deterministic inputs: `model`, resolved `prompt`, `system_prompt`, `temperature`, `max_tokens`, and `response_format`. Each model in a fallback chain has its own cache entry.

**Scope**: cache is prompt-scoped and space-isolated. Identical prompts with the same model and parameters within a space share cache entries regardless of which workflow produced them. Different spaces never share cache.

**On cache hit**: the step returns immediately with zero token consumption. `result_artifacts` includes `cache_hit: true`, `original_tokens` (the original token count when the response was first generated), and the `model` that produced the cached response.

**On cache miss**: after a successful API call, the response is stored in the cache with the configured TTL.

| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean | `false` | Enable response caching for this step |
| `ttl` | integer | `3600` | Cache time-to-live in seconds. `0` means no expiration |

**Rules:**

- Caching is opt-in — steps without `cache` never interact with the cache
- Only successful responses are cached — failures are never stored
- Cached responses count 0 tokens toward budget enforcement
- Structured output (`response_format: json_object`) is cached and round-trips correctly
- With fallback: cache is checked per-model before each API call in the chain
- Expired entries are cleaned up on read (no background eviction)

### Usage Tracking

LLM steps record token usage in `result_artifacts`:

- `model` — the model used
- `prompt_tokens` — input tokens consumed (0 on cache hit)
- `completion_tokens` — output tokens generated (0 on cache hit)
- `total_tokens` — sum of prompt + completion (0 on cache hit)
- `cache_hit` — boolean, true if the response came from cache
- `original_tokens` — original token count when the response was first generated (cache hits only)

These counts feed into the run's budget tracking the same way agent runner steps do. Cached responses consume zero budget.

### Structured Output

When `response_format: json_object` is set, the engine requests JSON output from the provider and parses the response. The parsed object is stored in `result_artifacts.structured_output`, accessible from downstream steps via `context_from`.

```yaml
steps:
  - id: extract_metadata
    type: llm
    prompt: "Extract title, author, and date from: {document}"
    response_format: json_object
    model: gpt-4o-mini

  - id: use_metadata
    type: runner
    runner: cli_claude
    prompt: "Process the extracted metadata"
    context_from:
      - step: extract_metadata
        field: result_artifacts.structured_output
```

**Provider behavior:**

| Provider | Behavior |
|---|---|
| OpenAI | Native `response_format` parameter — model is constrained to produce valid JSON |
| LiteLLM | Native `response_format` parameter — passed through to underlying provider |
| Anthropic | Best-effort — JSON instruction appended to system prompt. `response_format` param is ignored by the Anthropic SDK |

**Error handling:** If the LLM response is not valid JSON, the step fails with a clear error message. The raw response text is preserved in `result_artifacts.raw_response` for debugging. Retry configuration can recover from transient JSON parse failures:

```yaml
  - id: extract
    type: llm
    prompt: "Return JSON: {query}"
    response_format: json_object
    retry:
      max_attempts: 2
      backoff: fixed
      initial_delay: 1
```

### LLM Steps Inside Loops

LLM steps are allowed inside loop steps alongside runner and gate steps:

```yaml
- id: refine_loop
  type: loop
  max_rounds: 3
  steps:
    - id: draft
      type: llm
      prompt: "Write a draft based on: {topic}"
    - id: review
      type: llm
      prompt: "Review and improve this draft"
      context_from:
        - step: draft
          field: result_summary
```

## Publish Steps

Publish steps deliver content to an external destination via an adapter. V1 ships with `file` (local filesystem) and `webhook` (HTTP POST).

```yaml
steps:
  - id: generate
    type: runner
    runner: shell
    command: "build_report.sh"

  - id: publish_report
    type: publish
    context_from:
      - step: generate
        field: raw_output
    destination:
      adapter: file
      path: "{output_dir}/report.txt"
```

### File Adapter

Writes content to a local file path. Parent directories are created automatically.

```yaml
destination:
  adapter: file
  path: "/tmp/output/report.txt"
```

Path security: the file adapter validates paths against the same blocked-path rules as the `write_file` tool. Path traversal attacks are rejected.

### Webhook Adapter

Posts content to an HTTP endpoint. Supports custom headers, body templates, and credential injection.

```yaml
destination:
  adapter: webhook
  url: "https://hooks.example.com/workflow"
  method: POST
  headers:
    Content-Type: "application/json"
  body_template: '{"text": "{content}"}'
```

Webhook rules:

- URLs are validated against the egress allowlist (`ai.allowed_domains`, `ai.block_localhost_api`)
- Static URLs are validated at run startup; templated URLs (containing `{`) are validated after template resolution at execution time
- Payload size is capped at 1MB
- Credentials can be injected as `X-` headers via the step's `credentials` field

### Publish Steps and `when`

Publish steps support `when` clauses for conditional delivery:

```yaml
- id: publish_on_success
  type: publish
  when:
    step: deploy
    field: result_status
    equals: "success"
  destination:
    adapter: webhook
    url: "https://hooks.slack.com/services/T00/B00/xxx"
```

### Emit Steps

Emit steps produce a message event without executing any external work. Use them for status messages, progress reporting, or workflow summaries.

```yaml
- id: notify_start
  type: emit
  message: "Starting deployment for {target_name}"
  level: info  # info | warning | success | error (default: info)
```

| Field | Required | Description |
|-------|----------|-------------|
| `message` | Yes | Message template (supports `{variable}` substitution from inputs) |
| `level` | No | Severity level: `info`, `warning`, `success`, `error`. Default: `info` |

Emit steps are allowed inside loops and parallel branches.

The emitted message is recorded as a `step_emitted` event and displayed in both the CLI and web UI.

### Workflow-Level Summary

The `summary` block defines templates that are resolved and stored on the run when it completes or fails:

```yaml
summary:
  on_success: "Deployed {target_name} successfully"
  on_failure: "Deployment failed for {target_name}"
```

The resolved summary is stored in the run's `result_summary` field, shown in the CLI timeline and web UI run detail view.

## Token Tracking and Cost Estimation

Token usage is tracked automatically for all agent runner steps. The engine records prompt tokens, completion tokens, and model name in each step's `result_artifacts`. These are aggregated into the run's `budget_usage`:

- `prompt_tokens` — total prompt tokens across all steps
- `completion_tokens` — total completion tokens across all steps
- `total_tokens` — sum of prompt + completion
- `estimated_cost_usd` — cost estimate based on `cli.usage.model_costs` rates

Cost estimation requires `model_costs` to be configured (either the built-in defaults or custom rates in `config.yaml`). If no rates are available, `estimated_cost_usd` remains 0.

Token tracking is always on, even without a budget policy. Budget enforcement (`policies.budget.max_tokens`) uses the same counters.

## Current Limits

What definitions do not do yet:

- browser-side authoring
- arbitrary branching beyond registered conditions and loop control
- generic workflow catalog management in the web UI

## See Also

- [Workflow Concepts](concepts.md)
- [Workflow Commands](commands.md)
- [Workflow Monitoring](monitoring.md)
