Skip to content

YAML Schema Reference

Complete reference for workflow.yaml fields. Every field documented here is validated at realm workflow register time — errors include the field name and expected type.

An unrecognized top-level or step key (a typo, or a field from a removed feature) is refused by realm workflow validate, register, and watch (issue #170). The message names the key, the line it is on, and — when it is a close match of a real one — the right key: ⚠ step 'sync_data': unknown key 'dependson' (line 14) — ignored (did you mean 'depends_on'?). A second line then states the refusal itself and names which warning triggered it — the — ignored clause is a statement about what the parse did with the key, true on every surface, not about whether this run goes on to refuse the workflow over it (issue #540). --strict is no longer needed for this class — it now only tightens the warnings that remain warnings. The one exception: a top-level x- key is the author’s own extension namespace (issue #559, below) and is never refused, on any surface.

Source positions (issue #392) appear on loader diagnostics wherever the key can be placed exactly: the prose carries the start line, and the structured warnings channel carries the full range (line, column, endLine, endColumn, 1-based). The prose form tells you WHICH line it found, so the two are never confused: (line N) is the offending KEY’s own line, and (step at line N) is the step’s. The rewritten prohibition messages (nine today; the rest of the family follows) name the key’s own line where the source position resolves, falling back to (step at line N) and then to no position at all; every other step-scoped error, including the not-yet-rewritten refusals, names its step that way too. All four range fields are present together or absent together, never partially — and they are absent rather than approximate when a position cannot be resolved exactly, because a wrong line number is worse than none. Two shapes resolve to absent by design: a mapping the parser cannot pair key-for-key (a merge key, for instance), and any step generated by use_template, whose keys are synthesized rather than authored and so exist at no line in your file.

Already-deployed workflows keep running. realm workflow run, realm agent, and realm listen load leniently: an unknown key there is still dropped with a warning (— ignored, the same clause the refusing surfaces print, since it is true of what the parse did on every path — issue #540), so upgrading does not strand a workflow in production. The refusal stops new offenders entering, it does not evict existing ones. To find them before you upgrade, run realm workflow validate --strict on your current version.


Field Type Required Description
id string Yes Unique workflow identifier. Used in all CLI commands and MCP tool calls.
name string Yes Human-readable workflow name.
description string No Declarative statement of what this workflow is for / when to use it — distinct from protocol.quick_start (imperative “how to begin”). Surfaced in the agent protocol (get_workflow_protocol) and echoed by realm workflow validate/register. No synthesized default: absent means omitted, never fabricated.
version integer Yes Workflow version number, as declared; re-registering overwrites the stored copy and keeps the declared number.
params_schema object No JSON Schema for the run’s params. Validated as a schema at load; applied on every surface that starts a run — start_run, start_run_batch, realm workflow run, realm agent and listen — a violation refuses the start and no run is created. Every one of those surfaces receives the complete param set at creation — there is no partial-override door — so the schema is applied identically on all five. The agent’s call_with.params skeleton is derived from it at run time.
services object No Named service definitions. Referenced by steps via uses_service.
steps object Yes Map of step name → step definition.
protocol object No Optional protocol customisations. See Protocol.
profiles_dir string No Path to agent profile files, relative to the workflow YAML. Defaults to profiles/ in the same directory.
workflow_context object No Named file entries loaded once at run start and available in all step prompts. See Workflow context.
context_wrapper string No Wrapper format applied to {{ workflow.context.NAME }} references. One of xml (default), brackets, none.
mcp_servers array No External MCP server definitions. Steps reference these via tools. See MCP servers.
trigger object No Webhook trigger. When set, realm listen routes inbound webhooks to this workflow. See Webhook trigger.
extensions string | string[] No Project extension module path(s), relative to the workflow directory. See Project extensions.

Field Type Required Description
description string Yes Human-readable step description. Appears in the agent protocol.
execution agent | auto | guard | finalizer Yes Who executes this step. See Execution modes.
depends_on string[] No Step IDs this step waits for. Empty array or omitted means eligible from run start.
trigger_rule 'all_success' | 'all_failed' | 'all_done' | 'one_failed' | 'one_success' | 'none_failed' No When to evaluate dependency satisfaction. Default: all_success. See trigger_rule.
when string | string[] No Condition controlling step eligibility — a step is ineligible until truthy. A string[] is the implicit AND of its leaves. See when condition.
uses_service string No Name of a service declared in services. Only valid on execution: auto steps.
service_method fetch | create | update | delete No Adapter method to call. Defaults to fetch.
operation string No Operation name passed to the adapter. Defaults to the step name.
handler string No Name of a registered StepHandler to invoke. Only valid on execution: auto steps.
config object No Static key-value configuration passed to the handler via context.config, or merged into the adapter config for uses_service steps. Values may be any JSON value — scalars, arrays, and nested objects. For uses_service steps the adapter’s config_schema is the validator. Only meaningful on execution: auto steps with a handler or uses_service.
input_schema object No JSON Schema validated against the agent’s submitted params before execution. Also drives the call_with.params skeleton returned to the agent in next_actions.
output_schema object No JSON Schema validated against the agent’s submitted params before the engine claims the step. Only valid on execution: agent steps — declaring it on execution: auto steps is a loader error. Failed validation returns agent_action: provide_input and leaves the step unclaimed — immediately re-submittable without side effects.
structured_output 'strict' No Opts this step into Anthropic grammar-constrained (“strict”) decoding for its submit tool. Only valid on execution: agent steps. Rejected at load time if the step’s effective schema (output_schema ?? input_schema) is ineligible. On a step that declares tools, strict instead targets the tool-call arguments, per tool, assessed at runtime (issue #311). See structured_output.
preconditions string[] No Boolean expressions evaluated before the step runs. Not valid on execution: guard steps — a guard evaluates only abort_unless, so a precondition there is a loader error (issue #369). See Preconditions.
trust 'auto' | 'human_confirmed' | 'human_reviewed' No Human oversight level, on auto/agent steps — guard accepts none of the three, finalizer accepts only 'auto' (see Execution modes). Any other value is a load error (issue #508). See Trust levels.
timeout_seconds integer No Step execution timeout in seconds. On expiry the run fails with STEP_TIMEOUT. Enforced on execution: auto; consumed by execution: finalizer (drain lease + handler bound). A load error on execution: agent — nothing enforces it there, so the step would look time-bounded while nothing bounded it. In realm’s own drive, an agent step’s model request is bounded by llm_timeout_seconds (or --llm-timeout) and its tool calls by tool_timeout; a step driven by an external agent gets neither.
idempotent boolean No Attests the step’s work is safe to re-apply. Only valid on execution: auto steps — a load error anywhere else. Gates retry.on_timeout and reclaim eligibility.
retry object No Retry configuration. See Retry.
instructions string No Agent-facing instructions. Delivered as gate.agent_hint when a gate is open.
prompt string No Template-resolved task prompt delivered via next_actions[].prompt. On human gate steps, delivered as gate.display. Supports {{ context.resources.STEP.FIELD }} and {{ run.params.FIELD }}.
gate object No Gate configuration. gate.choices lists the valid human response values — when declared, must be non-empty (a load error otherwise, issue #433: an empty list mints a gate no response can ever resolve); omit the key to fall back to input_schema.properties.choice.enum or the default pair (approve/reject). gate.message is a developer-authored template string shown to the human reviewer. See Gate message.
input_map Record<string, InputMapNode> No Maps param names to values from the run context. Valid on execution: auto steps with either uses_service or handler. Each value is a dot-path string (run.params.<key> or context.resources.<step>.<field>), a nested object whose leaves are dot-path strings, or a { $literal: <value> } node holding a constant. See input_map values and $literal. Maximum nesting depth is 10. The resolved params are recorded in the evidence chain as resolved_params and are visible in realm run inspect as a Resolved: line.
agent_profile string No Agent profile name. Only valid on execution: agent steps. Must match a file in profiles_dir.
tools string[] No Tool names this step may call, in server_id:tool_name format. Only valid on execution: agent steps with an input_schema. References entries in mcp_servers. All declared names must exist in the connected server — if any name is not found after listTools(), the step fails immediately with MCP_TOOL_NOT_FOUND before any LLM call is made. If two connected servers expose the same bare tool name within a single step, the step fails immediately with MCP_TOOL_NAME_COLLISION.
max_tool_calls integer No Maximum number of tool calls the agent may make in a single step execution. Must be a positive integer.
max_fan_out integer No Maximum number of start_run or start_run_batch MCP tool calls the agent may make in a single step execution. Once this limit is reached the agent enters final-extraction mode (same as max_tool_calls exhaustion) — no further fan-out calls are executed, but the response is returned cleanly. Must be a positive integer.
tool_timeout integer No Timeout in seconds for each individual MCP tool call, applied by realm’s own drive (realm agent); a step driven by an external agent makes its own tool calls and is unaffected. Positive integer; defaults to 30 when absent. Requires tools — a step with no tools (or an empty list) never makes a tool call, so the key would bound nothing; declaring it there is a load error.
abort_unless string | string[] No One or more condition expressions evaluated against prior step evidence. If any condition is false, the run is aborted with run_phase: 'aborted'. Only valid on execution: guard steps. Absent path → GUARD_RESOLUTION_ERROR (run_phase: 'failed', not 'aborted'). See execution: guard.
abort_message string No Human-readable message recorded in the guard step’s evidence entry and in aborted_at.abort_message when the run aborts. Only valid on execution: guard steps.
on_outcome FinalizerTrigger | FinalizerTrigger[] Yes* Terminal trigger(s) this finalizer fires on: complete | fail | abort | always | completed_with_failed_steps. An array is OR-membership. Required and non-empty on execution: finalizer steps (*not applicable to other execution modes). See execution: finalizer.

Each input_map value is one of:

  • A dot-path string — resolved against run state (run.params.<key>, context.resources.<step>.<field>).
  • A nested object — recursed into; its string leaves are resolved as dot-paths, so you can assemble a structured param from prior step evidence.
  • A $literal node{ $literal: <value> } passes <value> through verbatim, with no path resolution. The value may be any JSON value: a string, number, boolean, null, array, or object. $literal is the escape hatch for constants — including a string that happens to look like a dot-path.
input_map:
# Constant scalars
table: { $literal: 'CS_Macros' }
limit: { $literal: 30 }
# Constant array (passed through verbatim)
tags: { $literal: ['urgent', 'billing'] }
# Constant object — leaves are NOT path-resolved, even if they look like paths
options:
$literal:
recursive: true
exclude: ['tmp']
note: 'run.params.x' # stays the literal string "run.params.x"
# Templated path resolved against run state, alongside the literals above
ticket_id: run.params.ticket_id

A $literal node must have exactly one key ($literal); sibling keys are a loader error. A whole $literal subtree is literal — the loader does not recurse into it and the runtime returns it as-is.

One constraint: a bare array as an input_map node value is not allowed — wrap it in $literal (tags: { $literal: ['a', 'b'] }, not tags: ['a', 'b']). A bare array is ambiguous between a literal and a future templated-array feature, so it is rejected at load time.

$literal is currently the only supported directive, and the entire $ prefix is reserved for directives. Any input_map object key starting with $ that is not a supported directive is an error — at both layers:

  • Load timeloadWorkflowFromFile / validate / register reject the workflow, naming the step, the param path, the offending key, the supported set, and the fix (with a did-you-mean suggestion for a near miss like $litteral).
  • Run timeresolveInputMapNode throws INPUT_MAP_UNKNOWN_DIRECTIVE, failing the step loudly. This layer exists for definitions already registered before the gate shipped: a stored workflow re-executes its own corruption on every run, so authoring-time validation alone would leave it producing garbage indefinitely.

The same applies to $literal carrying sibling keys — legal-looking, and previously resolved as a path under a key literally named $literal.

Why so strict. An unknown directive used to be accepted, then resolved as a context path, yielding undefined; the adapter then dropped the mistyped param and the step reported success. A production workflow shipped filter_by_formula: { $template: "…" } — a directive that has never existed — and queried Airtable unfiltered for five weeks, corrupting 907 downstream records while every layer looked healthy. A $-prefixed key is unambiguous author intent, so guessing is never the right response to one.

To pass literal data that contains $-keys, wrap the subtree in $literal — the loader does not recurse into it, so its keys are data:

input_map:
payload:
$literal:
$template: 'this is just a string key' # data, not a directive

Note there is no templating in input_map: values are context paths, nested maps, or $literal. Building a string from parts is not supported (tracked separately).

Mistyped adapter params fail loudly too. A scalar param that is present but of the wrong type now raises ADAPTER_VALIDATION_FAILED naming the adapter, operation, param, expected type and found type, instead of being dropped. An absent, null, or undefined param is still simply omitted — unresolved optional paths arrive that way routinely, so that arm must never throw.


The engine pauses and returns next_actions containing this step. The AI agent (or realm workflow run in dev mode) calls execute_step with the step’s command and params. The engine validates params against input_schema (if declared) and output_schema (if declared) before proceeding.

Every authored JSON-Schema block — the workflow’s params_schema and a step’s input_schema, output_schema and trace_schema — is compiled at load with the SAME validator realm runs with, so validate, register, validate --registered, watch, workflow test, realm workflow run, realm agent, realm listen and the public string loaders all give the same verdict. A block that does not compile is a load error citing the offending keyword’s own line wherever the validator points at one (the block’s key line otherwise), in realm’s own sentence — the offending keyword, what it must be (the allowed values or the expected shape) or that it is not a JSON-Schema keyword, the value as written, the path, and a remedy that names what to type (issue #586). The opener tells the truth per class: a meta-schema failure opens is not a valid JSON Schema; a strict-mode refusal — an unknown keyword, a format — opens is refused by realm's validator, because such a block IS valid JSON Schema by the spec and it is realm’s validator that declines it. One refusal per block per pass — the validator stops at its first compile error, so a block with two defects takes two passes; blocks still compose across the file (Invalid workflow — N errors:).

The classes an author hits:

class example note
an invalid keyword value type: banana
an unknown keyword foo: 1, and any x-… keyword INSIDE a schema block the x- namespace of issue #559 is the TOP of a workflow file only; inside a schema block realm’s validator refuses every unknown keyword
a non-schema where a schema belongs properties: {a: 1} a nested non-schema gets the validator’s own text, whatever it is (properties: {a: null} reads the same as properties: {a: 1}); a WHOLE block that is null gets its own sentence instead; true, false and {} are legal schemas (false rejects every value — a choice, not a defect)
an unknown format format: email realm’s validator ships no format plugin
a dangling $ref $ref: "#/definitions/nope" a local pointer names the definition the block must add; a pointer outside the block is refused outright — realm fetches no remote schemas and resolves no $id
an empty enum enum: [] issue #433 deliberately left this loading as “a schema-validity question”; it is refused from this release

A block that COMPILES but trips the validator’s strict mode — a union type: [string, number] is the common one — is not refused: the validator accepts it and the run time honours it. It surfaces as a SCHEMA_STRICT_ADVISORY warning on validate, register and validate --registered (escalated to a failure by --strict, carried on --json under diagnostics), citing the line of the keyword it is about (on --json, key names the block and line/column point at that keyword — two granularities, by design). A stored copy audited by validate --registered has no source file and carries no line. Nothing is printed at run time.

The advisory carries realm’s own remedy beside the validator’s sentence (quoted with the block’s key in place of the validator’s # root, as the refusals are), because the validator’s advice names a construction option no author can set. A union type: [string, number] becomes anyOf: [{type: string}, {type: number}] — realm does not enable Ajv’s allowUnionTypes. A keyword declared without its type (minLength with no type: string) gets type: added beside it, or the keyword removed.

The engine executes this step immediately without returning to the caller. If the step declares uses_service, the engine calls the registered adapter. If it declares handler, the engine calls the registered StepHandler. Auto steps chain automatically: after any step completes, if the next step is auto, the engine runs it immediately and repeats until it reaches an agent step, a human gate, or a terminal state.

An execution: auto step may declare none of uses_service, handler, or a human trust gate. This is valid — not a loader error — and is an intentional pattern for a step whose role is purely structural: a place in the DAG, typically the run’s last step, that requires no adapter/handler computation and no human review.

What it is. With none of uses_service / handler / trust set, the engine has nothing to compute for this step: no adapter is called, no handler runs, no gate opens. It is still sequenced by depends_on / trigger_rule / when like any other step, and it still produces an evidence entry and moves to completed_steps when it settles.

What its output actually is. Whatever value is already in flight on the underlying execute_step / auto-chain call becomes this step’s recorded output — an empty object if it’s the first thing a fresh call reaches, or, when it is auto-chained immediately after another step within the same call, a copy of that other step’s own submitted output (one dispatcher serves an entire auto-chain hop; the engine does not solicit separate input per bare step). Treat a bare auto step’s own output as inconsequential — never reference it (context.resources.<step>.*) from a downstream step. Its value is a byproduct of chaining, not an authored result; what matters is that the step — and with it, the run — completed.

When to use it. A terminal “record/checkpoint” step: the last node of the DAG, whose completion is what marks the run done, not whatever data lands in its evidence. Give it a correct depends_on naming its real predecessor(s) so it cannot become eligible — and therefore run — before the actual work finishes.

steps:
do_the_work:
description: Perform the real work
execution: agent
depends_on: []
finalize:
description: Terminal checkpoint — no output of its own
execution: auto
depends_on: [do_the_work]

How it differs:

  • From a handler/adapter step (handler / uses_service set): those have the engine compute a real, meaningful output.
  • From a gate (trust: human_confirmed / human_reviewed): a gate pauses the run for an explicit human decision; a bare step never pauses — it settles the instant it’s reached.
  • From execution: agent: an agent step is surfaced in next_actions and requires an explicit execute_step call carrying agent-authored params; a bare auto step is never surfaced in next_actions and settles automatically as part of the auto-chain.

This is intentional, not an authoring error.

The engine evaluates one or more boolean expressions declared in abort_unless against the run’s current evidence. The evaluation happens inline, as part of the auto-chain — the guard is never returned to the agent as a step to execute.

  • If all conditions are true: the guard passes, goes into completed_steps, and the run continues.
  • If any condition is false: the run is aborted immediately. The guard goes into skipped_steps, run_phase becomes 'aborted', and get_run_state includes abort_context with the evaluated conditions. No further steps execute.
  • If a path in abort_unless cannot be resolved (absent evidence field): the guard fails with GUARD_RESOLUTION_ERROR, run_phase becomes 'failed'. This is an authoring error — fix the path.

All conditions are always evaluated regardless of intermediate outcomes — the evidence record is complete whether the guard passes or aborts.

Guard steps are incompatible with: uses_service, handler, input_schema, output_schema, trust, agent_profile, trigger_rule, timeout_seconds, service_method, operation, input_map, tools.

preconditions is prohibited too, and for a sharper reason than the rest (issue #369): a guard’s execution evaluates only abort_unless, so a precondition declared on one is never evaluated at all. Before this was a loader error, such a workflow loaded happily and ran with the declared check silently absent — it looked guarded and was not. Put the condition in abort_unless, where a guard actually reads it. Whether guards should gain a live condition surface of their own is an open design question (issue #366); if it is ever admitted, workflows written today are unaffected.

steps:
classify_ticket:
description: Classify the support ticket
execution: agent
depends_on: []
guard_must_be_open:
description: Abort if the ticket is not open
execution: guard
depends_on: [classify_ticket]
abort_unless:
- "classify_ticket.status == 'open'"
abort_message: 'Ticket is not open — aborting run.'
route_ticket:
description: Route the open ticket to the correct team
execution: agent
depends_on: [guard_must_be_open]

Run phase after abort: 'aborted'. This is a terminal phase — aborted runs cannot be resumed. The aborted_at field on the run record (and abort_context in get_run_state) contains the guard step ID, all evaluated conditions, and the optional abort_message.


A finalizer step runs at the run’s terminal transition — a workflow-level try/catch/finally. It is never returned to the agent as work to execute; the engine dispatches it directly to a registered handler once the run seals. Declare one or more on_outcome triggers (below); every finalizer whose triggers match the sealing outcome runs, in declaration order, drain-ranked (a finalizer declared for BOTH a specific outcome and always runs once, via the specific-outcome group — never twice). Each finalizer runs at most once per run — a resumed/re-driven run never re-fires one that already settled. A finalizer handler’s own failure (a thrown error, a timeout, or a handler returning { abort }) is recorded as a non-fatal failure — it never mutates aborted_at, terminal_state, sealed_by, or skipped_steps, and never changes the sealed outcome (issue #367: WHICH arm sealed the run is fixed at the seal — a finalizer’s own failure never rewrites it); on a fail-class seal the one-line cause is re-rendered to include the finalizer’s own failure, so the sentence and failed_steps agree (#373). The run’s own already-sealed terminal outcome stands. An undrained finalizer (e.g. the process crashed between the terminal commit and delivery) is recoverable via realm run drain.

v1 loader constraints:

  • handler is required — v1 is handler-only (no uses_service, no agent dispatch).
  • trust must be absent or 'auto' — any other value is rejected, for one of two DIFFERENT reasons: a recognized gate literal (human_confirmed/human_reviewed) is rejected because a finalizer must not gate; anything else is rejected because it is not a recognized trust value at all (see Trust levels; issue #508).
  • on_outcome is required and non-empty — every value must be one of the five triggers below.
steps:
# ... domain steps ...
notify_on_failure:
description: Send an internal alert when the run doesn't complete cleanly
execution: finalizer
handler: slack-notify
on_outcome: [fail, completed_with_failed_steps]
on_outcome value Fires when…
complete The run seals completed (terminal_reason: 'Workflow completed.') — regardless of whether any step along the way failed and was recovered around.
fail The run seals failed.
abort The run seals aborted — a guard’s abort_unless condition was false, or a step handler returned { abort }.
always Any of the three seals above — a “finally” arm. A finalizer that also declares a specific outcome runs once (via that outcome’s own group), never twice.
completed_with_failed_steps The run seals completed AND failed_steps is non-empty at that seal (a designed-recovery completion — a SUBSET of the class realm run inspect’s completed_with_failed_steps run-health finding surfaces: that finding also fires on a finalizer’s own post-seal failure, which this trigger cannot see). Fires ONLY on that specific mixed-complete shape — it does NOT fire on a clean complete, and it does not substitute for fail. To cover both a pure fail seal and a mixed-complete seal with one finalizer, declare the array form: on_outcome: [fail, completed_with_failed_steps].

Note (mixed-complete, Airflow-style): a workflow that recovers around a step failure via trigger_rule (the way Airflow’s own trigger-rule DAGs route around a failed task) still reaches a complete seal — fail-triggered finalizers do NOT run for it (the run didn’t fail). If you need a safety-net finalizer that also covers this recovered-but-scarred case, opt into completed_with_failed_steps explicitly; it is never implied by fail or by complete alone.

Second-epoch runs: the completed_with_failed_steps predicate is failed_steps.length > 0 at the seal, uniformly — it does not distinguish a failure from THIS epoch from a failure left over from an earlier one. A run that fails, has its fail-triggered finalizer itself fail (an unresumable step, so it stays in failed_steps forever), gets resumed, and then completes on its second epoch still fires completed_with_failed_steps — the prior epoch’s scar counts. The run-health finding of the same name tests the same predicate SHAPE, but it tests it at read time on the final record, whereas this trigger is evaluated at seal time. A finalizer’s own later failure therefore shows up in the read-time finding and cannot retroactively fire this trigger — by the time that failure exists, the seal has already minted. The divergence is tracked at #374.

realm run abandon never runs finalizers, in any epoch. abort is the graceful terminal path and runs finalizers normally; abandon is an operator kill switch for a run that cannot be aborted normally, and stays a kill — declared finalizers (if any) do not run. Both the abandon_run MCP tool and realm run abandon disclose this on every successful abandon.


Value Description
auto No human involvement. The engine executes and advances. Default.
human_confirmed The engine pauses and returns status: confirm_required. The run will not advance until submit_human_response is called with a valid gate choice.
human_reviewed Reserved. No distinct “challenge” mechanism is implemented — today this is an exact alias of human_confirmed at the gate mint. Issue #531 owns implementing a real distinction or permanently aliasing it.

Any other value — a typo, the service trust literal used by mistake (engine_delivered / engine_managed / agent_provided — a different key, services.<name>.trust, not a step’s own trust:), the retired human_notified value, null, or any non-string — is a load error on execution: auto and execution: agent steps (issue #508). Two DIFFERENT things happen depending on when the value is caught, and they are deliberately not described the same way:

  • At load (validate/register/watch/run/agent/listen/test — every loader entry point): the workflow is refused before any run of it can be created. Had the value been let through, no gate would ever have opened and the step would have run unattended — but no run of this workflow exists to run it in, so nothing actually does.
  • At dispatch, immediately before the step runs: the engine refuses it again, for the population a load-time refusal cannot reach — every workflow already registered before this check shipped, not merely a hand-edited store record (though that is one way to reach it too). There, a run already exists: no gate opens and the step does not run, and the run is left parked, non-terminal, until the value is corrected.

Same remedy either way: correct the value, realm workflow register the corrected file, and retry.

trust is meaningful only on execution: auto and execution: agent steps. It is prohibited outright on execution: guard steps, and on execution: finalizer steps only the literal auto is accepted (harmless there — a finalizer must not gate; see execution: finalizer above).


Every step declares which steps must settle before it becomes eligible. Steps with an empty or omitted depends_on are eligible as soon as the run starts.

steps:
read_data:
description: Load data from disk
execution: auto
depends_on: [] # eligible immediately
uses_service: source
operation: read
analyze:
description: Analyze the loaded data
execution: agent
depends_on: [read_data] # waits for read_data to complete

The engine evaluates depends_on after every step settles. A step becomes eligible when its trigger_rule is satisfied given the current state of its dependencies.


Controls when a step becomes eligible based on how its dependencies settled. Default: all_success.

Value Eligible when…
all_success All deps completed successfully. Skipped if any dep fails. (default)
all_failed All deps failed. Use for recovery steps.
all_done All deps settled (completed, failed, or skipped in any combination).
one_failed At least one dep failed. Use for fallback steps.
one_success At least one dep completed successfully.
none_failed All deps completed or were skipped — none failed.

Use trigger_rule: one_failed or all_failed to implement error recovery:

steps:
extract_fields:
description: Extract structured fields from the input
execution: auto
handler: extract_fields_handler
depends_on: []
validate_fields:
description: Validate the extracted fields
execution: auto
handler: validate_fields_handler
depends_on: [extract_fields] # runs only when extraction succeeds
handle_extraction_error:
description: Notify team — extraction failed
execution: agent
depends_on: [extract_fields]
trigger_rule: one_failed # runs only when extraction fails

When a step fails (or is skipped), all downstream steps whose trigger_rule can no longer be satisfied are automatically moved to skipped_steps. For example, if extract_fields fails, any step with depends_on: [extract_fields] and the default trigger_rule: all_success is skipped immediately. The run terminates cleanly with run_phase: failed when no eligible or in-progress steps remain.

when-condition branches are skipped the same way: once all of a step’s dependencies are settled, if the step’s when expression evaluates to false, the engine moves it to skipped_steps immediately. In a mutual-exclusion pattern (two branches with opposite when conditions), the inactive branch is skipped as soon as the shared upstream step completes — the run closes cleanly without any finalizer step.

skipped_steps is included in realm run inspect and the get_run_state MCP response.


An optional expression evaluated against prior step evidence. A step is eligible only when both its trigger_rule is satisfied and its when expression is truthy:

steps:
classify_ticket:
description: Classify the support ticket
execution: agent
depends_on: []
handle_billing:
description: Route billing tickets to the billing team
execution: agent
depends_on: [classify_ticket]
when: "classify_ticket.category == 'billing'"
handle_technical:
description: Route technical tickets to engineering
execution: agent
depends_on: [classify_ticket]
when: "classify_ticket.category == 'technical'"

when is string | string[]. A single string is one leaf; an array is the implicit AND of its leaves (every leaf must hold). An empty array is a load error.

when:
- 'extract_order.order_number_found == true'
- 'resolve_store.store_key != null'

Leaf grammar: <path> <op> <literal> or a bare <path> (truthy test). Supported operators: ==, !=, >, <, >=, <=. The left side is a dot-path (step_name.field_name, or run.params.field). The right side is a quoted string, an unquoted number, true, false, or null. The split is quote-aware — an operator inside a quoted RHS (e.g. subject == 'a >= b') does not mis-split.

Compound and/or inside a single string is rejected at load — use the list form. The load error echoes the suggested list:

Step 'fetch_order': 'when' uses unsupported 'and' — write it as a list:
when:
- "extract_order.order_number_found == true"
- "resolve_store.store_key != null"

Reference rule (load-time): a when leaf’s step.field must reference either run.params.* or a step in this step’s direct depends_on (one-hop). Referencing a step not in depends_on is a load error (add it to depends_on or use run.params.*). Field names are not checked.

Comparison semantics. A resolved LHS uses strict equality (==/!=; "1" does not equal 1) and numeric-guarded relational comparison (> < >= <= require both operands to be numbers). When the LHS is unresolved (absent path):

  • == null / != null are presence tests (loose null) — == null is true for a missing or present-null value; != null is true only for a present non-null value.
  • relational ops (> < >= <=) → false (no null → 0 coercion).
  • any other operator on an absent LHS → false (symmetric: a != against a non-null literal does not fire when the path is absent).

Field-name typo fire-direction (accepted residual). Field names in a leaf are not statically checkable (agent-step outputs aren’t declared). A typo’d field resolves as absent in both directions: x.tcuont >= 0.8 → false → the step skips; but x.tcuont == null → “absent” → true → the step fires. This is consistent with Realm’s lenient path resolution everywhere. The high-value typo (a mistyped step name) is caught loudly by the reference rule above; only field-name typos within a correctly-named dependency slip through.

Once all of a step’s dependencies are settled, the engine evaluates the when condition. If it is false at that point, the step is moved to skipped_steps immediately. In a mutual-exclusion pattern (two branches with opposite when conditions), the inactive branch is skipped as soon as the shared upstream step completes. No finalizer step is needed to close the run.

To run a workflow in shadow mode — executing analysis steps but skipping side-effect steps — declare a mode param and annotate side-effect steps:

params_schema:
type: object
additionalProperties: false
properties:
mode:
type: string
enum: [live, shadow]
default: live
steps:
classify_ticket:
description: Classify the ticket
execution: agent
post_to_zendesk:
description: Post result to Zendesk
execution: auto
handler: zendesk_post
depends_on: [classify_ticket]
when: "run.params.mode == 'live'"

Start a shadow run with params: { mode: "shadow" }. Steps annotated with when: "run.params.mode == 'live'" are skipped. The run reaches terminal state cleanly — propagateSkips handles skip propagation once their dependencies settle.

Limitations: when: supports a single expression only. A step that needs both a shadow guard and a data condition cannot express both in one when: clause — use depends_on with trigger_rule or a preceding guard step instead. The step names run, context, and $settlement are reserved and cannot be used as step identifiers ($settlement reserved as of issue #220, ahead of a future namespace mint under that name — reserving it now closes the gap where an inter-release workflow could register a step that would become load-refused the instant the mint ships).


Boolean expressions evaluated against prior step evidence before the step runs. If any precondition is false, the engine returns status: blocked with agent_action: resolve_precondition.

Not valid on execution: guard steps. A guard’s execution evaluates only abort_unless, so a precondition declared there would never run — the workflow would look guarded while the declared check never happened. Declaring one is a loader error; put the condition in abort_unless instead. Whether guards should gain a live condition surface is an open design question (issue #366).

write_to_target:
execution: auto
preconditions:
- 'validate_fields.result.accepted_count > 0'

Supported operators: >, <, >=, <=, ==, !=. The left side is a dot-path into the evidence of a prior step (step.result.field). The right side is a literal value.


gate.message is a developer-authored template string shown to the human reviewer when a gate opens. It is distinct from promptprompt is the LLM’s task directive, while gate.message is a human-readable decision summary.

Primary use case — self-reference: the gate step’s own output is available via context.resources.STEP_NAME.FIELD, where STEP_NAME is this step’s own name:

confirm_update:
execution: agent
trust: human_confirmed
gate:
choices: [confirm, reject]
message: |
*Update Request*
Fields found: {{ context.resources.confirm_update.fields_found }} / {{ context.resources.confirm_update.total_fields }}
Missing: {{ context.resources.confirm_update.missing_fields }}
Confirm to proceed or reject to cancel.
prompt: |
Validate the incoming fields. Return JSON: { fields_found, total_fields, missing_fields }.

Cross-step references also work: {{ context.resources.prior_step.field }}.

Fail-fast behavior: if any {{ ... }} reference is unresolvable when the gate opens, the step returns a stop error immediately. The gate does not open with broken placeholder text. Fix the template or the step’s output schema.

gate.display fallback chain (MCP path):

  1. gate.message resolved → used as gate.display
  2. step.prompt resolved → used as gate.display (existing behavior, unchanged)
  3. Neither present → gate.display absent (existing behavior, unchanged)

Audit guarantee: the resolved message is stored verbatim in the run’s evidence chain. realm run inspect surfaces it in the gate_response entry under Message:, so the exact text the human read when they made their choice is preserved permanently.

Slack path: when gate.message is present, the resolved text is used in the Slack notification in place of the raw JSON preview. When absent, the existing formatGatePreviewForSlack(preview) fallback applies.

gate.message is a decision card — the minimal set of facts an operator needs to make their choice confidently. It is not a status report, not a content preview, and not a dump of the step’s output.

Structure pattern:

LINE 1 — Identity + the most important signal (severity, risk, category)
LINES 2–N — 2–4 scannable key facts (label: value format)
[blank line]
[action line — only when choices are not self-evident]
  • Target: 3–5 lines. Maximum: 8 lines. Beyond this, operators skim to the choices and miss the context.
  • Line 1 must uniquely identify what is being reviewed and surface its urgency signal. In Slack it renders as bold when wrapped in *...*.
  • Lines 2–N are for impact scope, confidence level, counts, or the one-line summary of the pending action.
  • The runtime appends the response instructions (realm run respond ...) automatically — do not include them.

What to include:

Include Reason
Identity — what specific thing this is Without this, every gate looks the same
Severity, risk, or confidence signal Tells the operator how carefully to review
Impact scope — services, users, count Tells the operator how much they’re committing to
The pending action in one clause What will happen if they approve
Breaking constraints or flags that affect the choice Things they’d want to know before saying yes

What to omit:

Omit Reason
Response instructions (Reply 'approve' to...) The runtime appends these automatically
Full document or report body Put that content in prompt; gate.message is a summary
Raw JSON arrays or objects Use | join, | bullets, or | count instead
Long strings without truncation Bind with | truncate: N to prevent layout blowout
Confidence notes when confidence is obvious Don’t clutter high-signal messages with noise

Anti-patterns:

# BAD: omits identity — impossible to tell what's being approved
message: |
Review this? Confirm to proceed or reject to cancel.
# BAD: dumps the content body — gate.message is a summary, not the content
message: |
Summary: {{ context.resources.write_summary.full_summary }}
# BAD: includes response instructions — the runtime appends these
message: |
PR #{{ run.params.pr_number }} detected.
Reply 'approve' to merge or 'reject' to discard.
# BAD: raw array value — renders as ["src/index.ts","src/utils.ts",...]
message: |
Changed files: {{ context.resources.scan.changed_files }}
# GOOD: shaped for reading
message: |
Changed files ({{ context.resources.scan.changed_files | count }}):
{{ context.resources.scan.changed_files | bullets }}

Channel rendering: the terminal renders gate.message as plain text — *bold* and other mrkdwn syntax appear literally. Keep messages plain text unless Slack is the primary surface. When Slack rendering matters, bold the headline only: *{{ context.resources.step.title }}*.

Checklist before shipping a gate:

  • Line 1 uniquely identifies what is being reviewed
  • Severity, risk, or confidence signal is on line 1 or 2 — not buried
  • All array fields are formatted with | join, | bullets, or | count
  • All long strings are bounded with | truncate: N
  • Optional fields use | default: or are guaranteed present by the step’s input_schema
  • No response instructions included
  • Total length is ≤ 8 lines
  • If content is truncated or capped (| limit:, | truncate:), the reviewer can either consult a primary source (URL, PR number, ticket) or — once bidirectional gate messaging is available — ask the agent for more detail in the same thread. Do not hide information that exists nowhere else and has no reachable primary source.
  • Tested in terminal rendering, not only previewed as Slack Markdown

gate.resolution_messages is an optional per-choice confirmation map displayed after the gate resolves. Each key is a valid gate choice; the value is shown in the terminal and posted as a Slack thread reply to the gate notification.

gate:
choices: [send, reject]
message: |
{{ context.resources.analyze_cause.severity | upper }} — {{ context.resources.analyze_cause.root_cause }}
Draft: {{ context.resources.draft_response.headline | truncate: 80 }}
resolution_messages:
send: 'Draft approved — posted to the incident channel.'
reject: 'Draft rejected — run cancelled.'

Values are plain text — no template substitution. Keep entries to one line. Every choice in gate.choices should have a corresponding entry; missing choices resolve silently (no message).


Gate timeout (authorable enforce + notify clocks)

Section titled “Gate timeout (authorable enforce + notify clocks)”
approve_deploy:
execution: auto
trust: human_confirmed
gate:
choices: [approve, reject]
timeout_seconds: 3600 # enforce clock: 1 hour
on_expiry: settle_default
default_choice: reject # safe default — never deploy unattended
reminder_seconds: 900 # notify clock: nudge every 15 minutes
reminder_max: 3 # up to 3 nudges (default when reminder_seconds is set)

Issue #291. Five gate: sub-keys, all optional and independent of each other except where noted:

Key Type Rule
timeout_seconds positive integer The enforce clock: seconds after gate-open after which the gate is eligible for enactment.
on_expiry 'settle_default' | 'abort' The disposition enacted once timeout_seconds elapses unanswered. Absent = finding-only mode (see below) — LEGAL.
default_choice string The choice enacted when on_expiry: settle_default. REQUIRED iff on_expiry: settle_default (a load-time hard error otherwise); validated against the step’s own effective choice set (gate.choices ?? input_schema.properties.choice.enum ?? ['approve','reject']) at load time — a load-time-legal default can never fail at enactment time. A declared-and-empty choice source — gate.choices: [], or (with no gate.choices list declared) input_schema.properties.choice.enum: [] — is a load error in its own right (issue #433), independent of default_choice: an empty source mints a gate no response can ever resolve, regardless of whether on_expiry is configured at all.
reminder_seconds positive integer The notify clock: seconds between reminder nudges while the gate is unresolved. Standalone-legal — does NOT require timeout_seconds (a pure-notify gate) — LEGAL.
reminder_max positive integer, default 3 Repetition cap on the reminder cycle (only meaningful when reminder_seconds is set).

Two documented distinct concepts, never confused:

  • The enforce clock (timeout_seconds/on_expiry/default_choice) is part of the workflow’s contract — it can settle or abort the run. Canon analogue: Camunda’s interrupting boundary timer / AWS Step Functions TimeoutSeconds.
  • The notify clock (reminder_seconds/reminder_max, plus the CLI operator’s own reminderIntervalMs/escalationThresholdMs config) has ZERO settlement authority — it can only send a message. A reminder being overdue is never a run-health finding (see the negative pin below); it never enacts anything. Canon analogue: Camunda’s non-interrupting boundary timer / dueDate / the C7 timeout task listener.

Dead-config warns (never rejects), on a step declaring the gate trust that makes the block live (trust: human_confirmed/human_reviewed): on_expiry declared without timeout_seconds (nothing will ever trigger it); default_choice declared with on_expiry: 'abort' or with no on_expiry at all (inert); reminder_seconds >= timeout_seconds (the first reminder would never fire before expiry). All are DEAD_GATE_CONFIG loader warnings. On any other step (issue #524) — any step, of any execution kind, whose trust is not one of those two gate literals (no trust at all, trust: auto, or a trust value the loader refuses) — the engine never mints a gate at all, so the whole gate: block is inert regardless of which keys are set; the loader emits ONE DEAD_GATE_CONFIG naming that cause instead of the per-key texts above (adding timeout_seconds to “fix” the on_expiry warning does nothing on this population — the block stays exactly as dead). An unrecognized gate: sub-key (a typo) is a separate UNKNOWN_GATE_KEY warning — the FIRST gate sub-key validation the loader has ever had. A guard or finalizer that ALSO declares gate trust is refused for the trust itself ('trust' is not valid on execution: guard steps … / … is not valid on execution: finalizer steps (a finalizer must not gate)); its per-key advisories still print beside that refusal — issue #524 tracks keying the advisory on the step’s kind.

Finding-only modetimeout_seconds with NO on_expiry — is legal and distinct from a fully-enforced gate: the gate never auto-resolves (there is nothing to enact), but a gate_expired_awaiting_drive run-health finding still fires once it passes its deadline (disposition 'finding_only' in the finding’s own evidence), realm run drain --expired lists it as “expired — finding-only” and never touches it, no enactment timer is scheduled for it, and the final reminder occurrence (if reminder_seconds is also set) uses a wording variant that never claims “will enact”. A human response to a finding-only gate is never refused, however overdue.

Every one of the five fields above is frozen into the run record at the exact moment the gate opens (never re-read from the workflow definition afterward — the ClaimRecord.deadline/issue #302 uniform-epoch-freeze precedent, generalized). Consequences:

  • Editing a workflow’s gate: block after a gate has already opened never applies to that already-open gate — only to the NEXT gate a re-registered definition opens. This is deliberate: it kills a definition-drift class where a changed default_choice could make an enactment attempt fail forever.
  • A gate opened by an old binary that predates issue #291 (or one whose gate: sub-keys the loader silently ignored under an even older binary) carries NONE of these fields — it is grandfathered: finding-silent (no gate_expired_awaiting_drive finding, since there is no expires_at to compare against), reminder-silent (the operator’s own reminderIntervalMs config is its only notify path), and never automatically enacted. This population never resolves itself; a human response is its only path forward. Mixed-fleet advisory: a workflow deployed with gate.timeout_seconds but driven by a binary older than issue #291 has that key silently ignored — the gate behaves as if untimed.

The enactment mechanism is a single pure arm-set (applyExpireGate) reused identically by every enactment point below — refusing a premature attempt (now < expires_at, verified server-side, never trusted from a caller’s own clock) and idempotently NOOPing a replay (two enactment points racing each other never double-apply). Expiry-WINS: once the enforce clock has genuinely passed, a late human response is refused with an honest, disposition-specific explanation rather than silently recorded as if it arrived in time — see the disclosure section below for the exact wording per cell.

Enactment points (any of these may observe and enact an expired gate — level-triggered, not a single dedicated daemon):

Point When it enacts
submit_human_response A late response to an already-expired gate triggers enactment first, then composes the honest refusal/NOOP.
execute_step Attempting any step while a sibling gate has expired enacts it first — may un-block the very step being attempted.
realm run reclaim Never enacts — DEFERS, with an advisory pointing at drain --expired (reclaim has no drain plumbing of its own).
realm run drain --expired Opt-in flag — bare drain (including batch --force) is byte-stable, terminal-only, and never touches a gate. With the flag: reports/enacts expired-and-enactable gates; an abort disposition’s terminalization flows into the same drain pass.
The attending process’s timer The CLI process holding a gate open (interactively, via realm agent, or via the Slack notifier) schedules ONE enactment attempt at the frozen expires_at.
realm listen --sweep-expired-gates <seconds> Opt-in flag, default OFF — a coarse, store-wide sweep; the only enactor that doesn’t require an attending process to be waiting on that specific run. Never drains finalizers itself (no extension registry for a workflow it hasn’t mounted) — logs the same drain --expired advisory.

Late-response disclosure — every enactment carries an honest, per-cell envelope: a same-choice late response to a settle_default gate is told “the outcome matches your choice, but it was settled by timeout; your response was not recorded” (never implying the human’s own answer was recorded); a conflicting choice is told which choice actually won and that theirs was not recorded; a response to an aborted gate gets a third, distinct terminal-run variant (“the gate expired and the run aborted per the workflow’s declared on_expiry — your choice was NOT recorded”) — never the misleading “cancelled when <another step> aborted the run” wording a genuinely-cancelled gate gets. realm run inspect/get_run_state both disclose enacted_via (submit/execute_step/drain/timer/listen) and the overdue duration on the triggering call’s own response.

Realm Canon analogue
gate.timeout_seconds Camunda interrupting boundary timer / AWS Step Functions TimeoutSeconds
on_expiry: abort Interrupting timer → terminate
on_expiry: settle_default Interrupting timer → default branch
gate.reminder_seconds/reminder_max Camunda non-interrupting boundary timer / dueDate / the C7 timeout listener
CLI reminderIntervalMs/escalationThresholdMs Operator-configured notify — Temporal/external alerting
gate_expired_awaiting_drive (run-health) No canon analogue — daemonless-specific: canon’s daemon enacts synchronously; realm discloses the window between expiry and the next drive instead.

Dead-notification advisory: the CLI warns once, at notifier wiring, when the OPERATOR’s own reminderIntervalMs/escalationThresholdMs could never fire before the gate’s frozen timeout_seconds elapses (the AWS Step Functions HeartbeatSeconds < TimeoutSeconds cross-validation, extended cross-domain). An AUTHORED reminder_seconds >= timeout_seconds gets the SAME check at LOAD time instead (the DEAD_GATE_CONFIG warning above) — the loader can’t see the operator’s CLI config, so this is genuinely two checks for two config sources, not a duplicate.

Fallback asymmetry (stated, not a bug): the AUTHORED reminder cycle repeats (up to reminder_max); the OPERATOR fallback (reminderIntervalMs, used only when the author declared no reminder_seconds — record-keyed precedence) stays single-shot, unchanged from before issue #291 — repeating it would silently change an existing deployment’s Slack volume under an unchanged config. The webhook-only Slack topology (no bot token) runs no timers at all — an authored reminder is silently inert there; the escalation one-shot itself is completely untouched by any of this.

create_workflow cannot author a gate at all (its step schema has no gate:/trust: block) — gates, including every field on this page, are YAML-only.


The display: field produces a formatted terminal summary printed after the step completes. Without display:, the CLI prints the raw JSON output. With display:, it renders the developer-authored template.

write_review:
execution: agent
depends_on: [fetch_pr]
display: |
Risk: {{ risk }}
{{ review_comment }}

display: uses a short-path renderer{{ field }} resolves against the step’s own output object. It does not support:

  • {{ context.resources.STEP.field }} — cross-step references
  • {{ run.params.field }} — run params
  • Liquid filters (| upper, | bullets, | truncate)

Unrecognised paths pass through as literal text. This is the most common authoring mistake — if you see {{ context.resources.write_review.risk }} in the terminal instead of a value, you are using context paths in display: where you should be using gate.message.

On execution: auto steps with trust: human_confirmed, display: is used as the gate.display fallback when gate.message is absent:

1. gate.message resolved → used as gate.display (Liquid filters supported)
2. display: resolved → used as gate.display (short paths only, no filters)
3. step.prompt resolved → used as gate.display (existing fallback)
4. none present → gate.display is absent

For gate steps that need filters or cross-step references in the decision card, use gate.message — not display:.


Template expressions support an optional pipe-filter chain: {{ path | filter1 | filter2: arg }}.

The path is resolved first. Each filter in the chain receives the current value and produces a new value. If any filter produces a type mismatch the placeholder is left intact ({{ path | ... }}). Unknown filters in gate.message templates cause a FILTER_UNKNOWN stop error.

Filter Arg Input Output
bullets string[] • item\n• item\n… — empty array → placeholder
join separator (default ", ") string[] items joined by separator
default fallback value (default "") any fallback when value is null or undefined; passes through "", 0, false unchanged
upper string uppercased string
lower string lowercased string
capitalize string first character uppercased, remaining characters unchanged
truncate max length (integer) string string cut at word boundary ≤ N + ; unchanged if already short enough

truncate does not auto-stringify numbers; ensure the value is a string in the step’s output if truncation is needed.

capitalize uppercases only the first character; remaining characters are not modified. "DATABASE_UNAVAILABLE" | capitalize"DATABASE_UNAVAILABLE", not "Database_unavailable".

Arg quoting and multi-arg syntax: Filter arguments follow the filter name after a colon. Multiple arguments are separated by commas. String arguments containing spaces or commas must be quoted with double or single quotes; the outer quotes are stripped. Unquoted arguments are trimmed. Examples: join: " / " (one quoted arg, passes /); replace: ",", " / " (two quoted args); truncate: 80 (one unquoted integer arg); yesno: "Active", "Inactive" (two quoted args).

default: fires on null or undefined only — not on filter errors. A short-circuit from a prior ok: false result (type mismatch or unknown filter in lenient mode) leaves the placeholder intact; default: is not reached. For example, {{ items | pluck: "name" | default: "none" }} where pluck produces a type mismatch short-circuits before default: — the result is the placeholder, not "none".

Filter chain example:

gate:
message: |
Issues found:
{{ context.resources.scan.issues | bullets }}
Summary: {{ context.resources.scan.summary | truncate: 200 }}
Repo: {{ run.params.repo | upper }}
Tags: {{ context.resources.scan.tags | join: ", " }}
Status: {{ context.resources.scan.status | default: unknown }}

Strict mode: gate.message is rendered in strict mode — an unknown filter name returns a FILTER_UNKNOWN stop error rather than leaving the placeholder intact. All other template call sites (prompt, instructions, gate.display fallback) are lenient: unknown filters leave the placeholder as-is.

Author note: If you need a fallback for optional fields that may also be the wrong type, ensure the step always outputs the field as a string or omits it — don’t rely on | default: to cover upstream type errors.

Filter Arg Input Output
pluck key (string) object[] array of values for key; absent keys and non-object items omitted
count array array length as string; empty array → "0"
limit max items (integer) array first N items; limit: 0[]
compact array array with null/undefined entries removed
replace search, replacement (both required) string replaces all occurrences of search with replacement; case-sensitive; empty search → placeholder
round decimals (integer, default 0) number rounded string
floor number largest integer ≤ input, as string
ceil number smallest integer ≥ input, as string
abs number absolute value as string
number_format decimals (integer, default 0) number locale-formatted string with thousands separator; locale is en-US
percent decimals (integer, default 0) number [0, 1] e.g. "85.7%" — input is a fraction, multiplied by 100
yesno yes label, no label (both optional) boolean "yes" / "no" by default; custom labels when two args provided; one arg falls back to defaults
and_join unknown[] Oxford comma join; empty array → placeholder
trim string leading and trailing whitespace removed
first array first element; empty array → placeholder
last array last element; empty array → placeholder
sum number[] sum of elements as string; empty array → "0"; non-number element → placeholder
flatten array one level deep flatten; does not recurse
split delimiter (required) string splits on delimiter string (any non-empty string); produces string[]; empty delimiter → placeholder
sort array lexicographically sorted copy; elements coerced via String() for comparison; stable sort
unique array deduplicated array; equality by JSON.stringify; property order in objects matters
title string first character of each whitespace-separated word uppercased; remaining characters unchanged; hyphens are not word boundaries
code string wraps value in single backticks for Markdown/Slack inline code; inner backticks not escaped — values containing backticks may produce malformed output; intended for single-line values
indent spaces (integer, required) string prefixes each non-empty line with N spaces; empty lines not indented
date preset (short, long, iso, time, datetime) — default short string (ISO 8601) formatted date in UTC; short"Jan 28, 2026"; unparseable string → placeholder
from_now string (ISO 8601) relative time string, e.g. "3 minutes ago" or "in 5 minutes"; uses Intl.RelativeTimeFormat
duration number (milliseconds) duration string, e.g. "1m 23s" or "45s"; negative → placeholder

All date output is in UTC. timeZone: 'UTC' is used in every Intl.DateTimeFormat call — output is deterministic regardless of server timezone.

split is the only Tier 2 filter that changes the value type from string to string[]. It enables chains like {{ run.params.csv | split: "," | compact | and_join }}.

Tier 2 filter example:

gate:
message: |
Review required for {{ run.params.repo | upper }}.
{{ context.resources.scan.findings | pluck: "title" | limit: 5 | bullets }}
Issues found: {{ context.resources.scan.findings | count }}
Confidence: {{ context.resources.scan.confidence | percent: 1 }}
Auto-fixable: {{ context.resources.scan.auto_fixable | yesno }}
Affected modules: {{ context.resources.scan.modules | compact | and_join }}

fetch_document:
execution: auto
uses_service: source
idempotent: true
timeout_seconds: 30
retry:
max_attempts: 3
backoff: exponential
base_delay_ms: 1000
on_timeout: true
total_timeout_seconds: 120
Field Type Description
max_attempts integer Total attempts including the first.
backoff linear | exponential | fixed Delay growth strategy.
base_delay_ms integer Base delay in milliseconds.
max_delay_ms integer Cap on the computed backoff delay.
on_timeout boolean (issue #140) Opt in to retrying this step’s own STEP_TIMEOUT in place, consuming a normal attempt. Requires the step to also declare idempotent: true — a hard error at load otherwise, since a timeout-retry can run concurrently with the still-in-flight original attempt. Only meaningful on execution: auto steps. Absent/false: a timeout stays terminal (unchanged from pre-#140 behavior).
total_timeout_seconds integer (issue #140) Total budget, measured on the monotonic clock, in seconds, across every attempt (a Temporal-ScheduleToClose-style cap). Standalone-legal — does not require on_timeout. When absent, every retry-configured execution: auto step is capped by DEFAULT at max_attempts × its own per-attempt timeout + the declared backoffs between attempts — so the default cap equals the step’s own declared schedule and only binds when a runtime wait (e.g. a rate-limit retry_after) pushes an attempt materially past it (event-loop scheduling overhead is charged to the budget, and a timer may fire early [typically under 1 ms] — the budget is never under-charged at the boundary, issue #573). When the cap is reached, the step settles as STEP_RETRY_EXHAUSTED (exhausted_by: 'total_timeout') instead of sleeping past it. At total_timeout_seconds equal to timeout_seconds the first attempt runs to its own timeout and, on timing out, exhausts the budget — it is never reported as clipped. Elapsed budget is measured on the monotonic clock; the persisted claim horizon stays wall-clock (it is anchored at claim time). Inert (warns) on a non-auto step.

Timeout-retry concurrency contract: on_timeout: true is an attestation that the step’s handler/adapter is safe to execute concurrently with itself — including a PARTIAL prior application (a committed prefix left by the aborted-but-still-possibly-in-flight original attempt). This is a stronger claim than idempotent alone, which only guarantees safe SEQUENTIAL re-application; declare both explicitly, the engine never infers one from the other.

Version skew: on an engine older than #140, on_timeout and total_timeout_seconds are unrecognized retry sub-keys and are silently ignored, so a declared cap goes unenforced — this fails safe: retry semantics are otherwise unchanged, only the bound is missing.

Non-auto steps (issue #218): any retry: sub-key on an agent/guard step draws an advisory (RETRY_INERT_NON_AUTO), since the built-in dispatch path never throws for those steps and so never consumes the block.


Two different mistakes get two different treatments, and the asymmetry is deliberate.

An unknown key is a mistake about the FILE — unless it is a top-level x- key, which is not a mistake at all (issue #559, below). You meant depends_on and typed dependson. The loader refuses it at the authoring boundary (validate, register, watch) and warns rather than refuses at execution, so a deployed workflow keeps running while its author fixes the typo (issues #169/#170). create_workflow stays lenient permanently — an agent that invents a field should be told, not blocked.

A KNOWN key in a place that ignores it is a false statement about the RUN, and that is refused everywhere. timeout_seconds on an agent step is not a typo; it is a bound the author believes exists. Nothing enforces it, so the step looks time-bounded and is not — and unlike a typo, the file gives no hint that anything is wrong. That is why these are errors rather than warnings even though the key is spelled correctly and the workflow would otherwise run.

Nine of these refusals — the rest of the family follows in an upcoming release — say four things today: what is not valid, what the key would have DONE (traced to its actual consumer), where it does work instead, and — where the question is genuinely open — under what condition it might be admitted later. A refusal that only says “invalid” leaves the author to guess which of those four they needed. Among the rewritten messages, one that carries no re-admission condition means nothing is currently open for that key — the fourth clause appears only where a genuine widening is on the board, never as boilerplate.

When a release removes a key you use, the key becomes unknown and the unknown-key rules above take over: validate, register, and watch refuse it at the boundary, while already-deployed workflows keep executing with an — ignored warning. Check your workflows against the new version’s realm workflow validate before pointing execution at it (the refusal contract at the top of this document).

A removed VALUE is the opposite (issue #508). A known key’s UNKNOWN VALUE is a false statement about the run in exactly the same sense a known key in the wrong place is (above) — the author declared something the engine cannot honor, and an already-deployed workflow gets no grace period for it. trust: human_notified (retired) is refused at the loader boundary like any other unrecognized trust value, AND — because the check also lives in the engine’s dispatch path, not only the loader’s — an already-REGISTERED workflow carrying it starts refusing at that step’s next dispatch too, with no — ignored warning and no grandfathering: the engine reads the current stored definition on every dispatch, so a stale registration is not a shelter. realm workflow validate --registered <id> (below) is how you find these before they refuse mid-run.

When the affected population is not zero. Where a shape is already in use, a straight break is the wrong tool. The ladder is: refuse, but ship a version-pinned escape hatch that silences the error for a named release and expires by itself; remove the escape at the next major. A silencer that does not expire becomes permanent, and the population never moves. No such escape hatch exists in realm today — none has yet been needed. This paragraph is the committed policy for the first refusal — a new prohibition or a removed key — whose population trace finds a deployed population; that change ships the mechanism.

Extension namespace (issue #559). A top-level key beginning x- is the author’s own — a place for a YAML anchor host (a shared enum, a reusable block) or a tooling note, exactly the way Docker Compose’s ^x- works, and Compose’s own docs direct anchors into it. It mints no warning on any surface, is carried verbatim into the registered copy, and realm’s loader and engine never read it. validate, register and watch name every accepted key on the verdict line — — 1 extension key carried, never read by realm: x-category-enum — so an author who believed one configured something is told otherwise, not left to find out the hard way.

x-realm- is reserved for realm’s own future extension keys from the day the namespace opens, so a key in it is refused today exactly like any other unrecognised one, with a message naming the reservation. The author’s namespace is lowercase: X-Foo is not an extension key, and at the top level it is refused with a message that says so. The reserved x-realm- sub-namespace is matched in every capitalization — x-Realm-foo is refused as reserved — so no case-variant can sit beside a future realm key. The namespace applies only at the TOP LEVEL: a step-level x- key is still refused, with a message pointing back to the top of the file (and, for a name inside the reserved sub-namespace, asking for a name outside it) — step keys are the closed consumption registry described below (issue #417 PR-2), where an inert key is a load error by ratified policy, and there is no namespace to open inside it. A genuine step-level need would add a new “extension, never consumed” class to that registry, not carve a hole in it.

The step-key consumption registry (issue #417 PR-2)

Section titled “The step-key consumption registry (issue #417 PR-2)”

The refusals above are the known-key story for the handful of keys realm has reclassified one at a time (#402, #413, #369, and this file’s own running list). The exhaustive, cross-checked version of the same story — every key in KNOWN_STEP_KEYS crossed with every execution kind, with the exact line in the engine or the loader that either reads it or refuses it — lives in packages/core/src/workflow/step-key-registry.ts. A conformance suite drives every prohibited, blocked_transitive, and inert row through the real loader and the real engine source on every test run, so the registry cannot drift silently from the code it describes.

Two shapes of “not valid” appear there beyond the plain, unconditional prohibition documented above: a key can be refused only in combination with another field’s absence — tool_timeout requires a non-empty tools list, and is refused only when that companion is missing, never on its own — and a key can be accepted on every kind while being genuinely read on only some, the rest tracked as a named, deliberate gap rather than silently dropped. Both are deliberate, beyond-field mechanisms: mature option registries elsewhere draw the same two-way distinction between an unconditional and a companion-conditioned restriction (Clang’s Options.td, for one); realm’s version additionally proves the distinction by execution on every test run, which no surveyed system does.

The drive-flip (issue #517). Since #517 the registry does not merely DESCRIBE the kind-prohibitions — it MINTS them (rung 1 of the ladder recorded in #417’s program map): the loader’s per-step walk looks every declared key up in the registry and mints exactly one refusal per key × kind, with the message text carried as per-cell data. The upgraded generic messages’ consequence clauses are witness-backed (rung 2): the clause that tells you where the key actually lives — “step eligibility reads it…”, “the engine’s execution loop enforces it…” — is rendered from the same consumed_home data whose derived kind-set, witness reference, and surface name the conformance suite asserts, so the message’s factual claims and the tested citation are one object. (One honestly-stated residual: a wrong VERB in the mechanism prose is not machine-caught.) Multi-fire is dead by construction — a key that used to draw two differently-worded kind verdicts on one step (agent_profile on a finalizer, for instance) now draws exactly one — and a step with several misplaced keys reports them in YAML declaration order. Companion and value rules remain hand-written by design: even Clang, whose Options.td drives its option refusals from data, draws the line at companion predicates.

The gate key’s empty-choices asymmetry (issue #433). A declared-empty gate.choices: [] is a load error on any step — refused, with the reason (an empty list mints a gate no response can ever resolve) and the remedy spelled out, regardless of whether the step is even gate-trusted. Its enum sibling (input_schema.properties.choice.enum: []) is refused only where it is the gate’s effective choice source: a gate-trusted step with no gate.choices list declared. On an ungated step the same empty enum loads clean. The asymmetry the registry records: the gate.choices: [] refusal fires on every execution kind, including guard and finalizer, where the gate key itself is never read (tracked as inert in the registry) — declared-empty is refused even where declared-anything else is otherwise merely inert. An empty source is never right on any kind, so it gets strictness where the rest of the key gets tracking.

llm_timeout_seconds (the per-attempt model-request ceiling)

Section titled “llm_timeout_seconds (the per-attempt model-request ceiling)”
classify:
execution: agent
llm_timeout_seconds: 30
Field Type Meaning
llm_timeout_seconds integer (issue #401) How long ONE model request may take, in seconds. Positive integer. Valid ONLY on execution: agent steps — a load error anywhere else, because no other kind makes a model request.

Per ATTEMPT, not per step. A step that needs several turns is not killed for the sum of them; each request gets the same ceiling. From it realm derives the bound it actually enforces on one create:

ceilingMs = perAttemptMs × (maxRetries + 1) + backoffMargin + 60_000
backoffMargin = Σ n=0..(maxRetries−1) min(0.5 × 2ⁿ, 8) seconds = 1.5s at maxRetries 2

The + 60_000 is a download allowance, so a large-but-progressing response is never killed for being large. The backoff term is the SDK’s OWN retry schedule summed as an UPPER bound — its jitter only ever shortens a wait, so budgeting the un-jittered sum can never cut short a retry the SDK scheduled for itself. A SERVER-DIRECTED wait is a different thing and is deliberately not budgeted: a Retry-After can ask for hours, and the ceiling outranks it on purpose. With the default 600s per attempt the derived ceiling is 1,861,500ms; with llm_timeout_seconds: 30 it is 151,500ms.

Precedence. The step’s own key wins. realm agent --llm-timeout <seconds> (or realm listen --llm-timeout, which passes it to every drive it spawns) fills in for every step that authored nothing. Neither ⇒ 600 seconds per attempt.

Where it applies. Realm’s own agent drive only — realm agent. The MCP execute_step path never runs through the drive, so a step driven by an external agent is unaffected. A provider supplied via --provider-module gets the drive-failure RECORD like any other, but not the bound: realm cannot impose a ceiling inside code it does not construct the client for. The VISIBILITY guarantee is universal; the BOUND is realm’s own providers.

Why the total drive is bounded (issue #401)

Section titled “Why the total drive is bounded (issue #401)”

Each ladder leg is gated on an HTTP status — 400/503 for the structured-output ladder, 400 for the tool-args ladder. A ceiling abort carries no status, so it can never engage a ladder leg and the legs cannot compound: the whole drive is bounded by (the existing, already-bounded turn and repair counters) × the per-create ceiling.

The ceiling forbids exactly two things the SDKs otherwise permit:

  1. The unbounded post-header hang — headers arrive, the body never finishes, and a request-level timeout that has already been satisfied never fires again.
  2. Unbounded server-directed sleeping — a Retry-After the SDK honours can hold a worker for as long as the server asks.

Retry-After is OBSERVED, never honored (a recorded design decision: visibility over obedience). Realm reads all three forms — retry-after-ms, a numeric Retry-After in seconds, and an HTTP-date Retry-After — and records the value on the failure as retry_after_observed_ms alongside last_observed_status. Those two fields are what let an operator tell a rate limit apart from a hang without reading a single log line. Realm’s own scheduling never waits on it; the SDK beneath may, and that wait is bounded by the ceiling.

A past date observes as 0 (the wait is already over), and a header nobody can parse observes as nothing at all rather than as a fabricated zero. attempts_sdk counts the attempts the wrapper saw COMPLETE — an attempt still in flight when the ceiling fired is not counted, so a request that hung before its headers arrived records 0.

Header note: realm now passes an explicit request timeout to both SDKs, so outgoing requests carry an X-Stainless-Timeout header they did not carry before. Servers ignore it; proxies that log headers will show it.


validation_exhaustion (bounded schema-rejection exhaustion)

Section titled “validation_exhaustion (bounded schema-rejection exhaustion)”
draft:
execution: agent
output_schema:
{
type: object,
additionalProperties: false,
required: [category],
properties: { category: { type: string } },
}
validation_exhaustion:
threshold: 3

Every execution: 'agent' step whose submitted output/input is rejected against output_schema/ input_schema (VALIDATION_OUTPUT_SCHEMA/VALIDATION_INPUT_SCHEMA) accrues a persistent, per-step rejection count on the run record (RunRecord.validation_rejections, pooled across concurrent writers, never reset). Once the count reaches a threshold — 6 by default (DEFAULT_VALIDATION_EXHAUSTION_THRESHOLD, exported), or the value declared here — the step terminalizes with a real VALIDATION_EXHAUSTED failure: it claims, fails, and seals exactly like any other dispatch failure. Finalizer triggers key on the RUN’s own sealing outcome, never on this one step’s failure in isolation — complete seal ⇒ on_outcome: complete + always finalizers fire; fail seal ⇒ fail + always; abort seal ⇒ abort + always. A run that recovers around this step’s exhaustion (via trigger_rule) and still reaches a complete seal fires complete + always finalizers only — fail-triggered finalizers do NOT also run just because this one step failed along the way, unless the mixed-complete trigger is declared; see execution: finalizer. This is deliberate (issue #220): a persistently-rejected agent step is otherwise an unbounded, write-free wedge — run_phase never reaches failed and finalizer machinery never fires. Every countable agent step is auto-enrolled at the default threshold — there is no way to disable exhaustion in PR-1, only to retune it.

Field Type Description
threshold integer Overrides the default threshold (6) for this step. Must be a positive integer. 1 is legal and documented as disabling in-drive schema-repair (realm agent’s --schema-retries), since the very first rejection already meets it.

Only valid on execution: 'agent' steps — the countable rejection classes are agent-only by construction.

mode ('fail' | 'default') and default_output (issue #220 PR-2) let a step declare a bounded, validated, disclosed fallback instead of failing on exhaustion: mode: 'default' requires default_output and requires the step to declare output_schema (against which default_output is AJV-validated at load time, using the exact same validator the runtime uses — a fallback that would itself fail runtime validation is refused before the workflow ever registers). On exhaustion the engine settles the step SUCCESSFULLY with default_output instead of failing the run. See $settlement below for how a downstream step can branch on whether this happened.

draft:
execution: agent
output_schema:
{
type: object,
additionalProperties: false,
required: [category],
properties: { category: { type: string } },
}
validation_exhaustion:
mode: default
default_output: { category: 'uncategorized', source: 'fallback' }

realm agent coherence warning: when --schema-retries’s own in-drive repair budget (schemaRetries + 1 attempts) exceeds a step’s effective exhaustion threshold, the drive prints one warning at that step’s first attempt — the repair loop would otherwise keep retrying past the point the engine has already terminalized the step.

create_workflow (dynamic workflows): validation_exhaustion is register-time only — a dynamically-created workflow cannot declare it, and draws a targeted warning naming the disposition if submitted. Every dynamic agent step with a countable schema is still auto-enrolled at the default threshold; there is no reachable override or opt-out for a dynamic workflow.


structured_output (Anthropic strict decoding)

Section titled “structured_output (Anthropic strict decoding)”
classify:
execution: agent
structured_output: strict
output_schema:
type: object
additionalProperties: false
required: [category]
properties:
category: { type: string, enum: [billing, technical, other] }

Issue #236. structured_output: strict opts an execution: agent step into Anthropic’s grammar-constrained (“strict”) tool use — the model’s token sampling is constrained so its submit-tool call matches the step’s effective schema (output_schema ?? input_schema) by construction. This is an L0 prevention layer: it narrows the class of malformed submissions that ever reach realm’s own (L1) Ajv validation + reask loop. L1 is not subsumed — three documented escape hatches (a refusal stop reason, max_tokens truncation, and enum-value casing drift) still produce schema-nonconforming output even under strict, so Ajv + reask remain the safety net regardless of whether this key is declared.

Default is OFF. Nothing changes for a step that omits this key — declaring it is the only way to engage any of the machinery below.

The API provably rejects some legal-per-Ajv schemas (a 400, “with details”) and silently weakens others — an unsupported keyword is neither honored nor rejected, it is simply dropped from the grammar with no error, so the model can violate it and realm’s own Ajv only catches that after the fact. Because two failure classes (internal grammar-size limits and a 180-second compile timeout) are documented as unpredictable from the schema alone, realm’s own static gate can never be complete — it narrows the surface, it does not eliminate live 400/503s. assessStructuredOutputEligibility (the same pure function both authoring time and runtime call) classifies the effective schema against this table:

Row Rule Verdict
G0 No effective schema at all, or the root is not type: 'object' ineligible (two distinct remediations: “add output_schema/input_schema” vs “declare type: 'object' at the schema root”)
G1 Every object (root and nested) must carry EXPLICIT additionalProperties: false — never injected by realm (injection would permanently split what the grammar allows from what Ajv allows) missing ⇒ ineligible
G2 Keyword allowlist (snapshot-dated from Anthropic’s own docs). Hard class: minimum/maximum/multipleOf · recursive schemas (including a root $ref: '#' — the API neither cleanly enforces nor cleanly rejects this specific edge: it silently prunes an optional recursive arm, or 503s when the arm is required) · external $ref · an enum with a complex (object/array) member hard class ⇒ ineligible; any OTHER off-allowlist keyword (e.g. minLength, pattern) ⇒ caveat — “silently ignored or rejected by the API — either way enforced post-hoc by realm”
G3 More than 24 optional properties, or more than 16 union-typed properties (anyOf or a multi-type type array), across the schema ineligible
G4 format outside the 10 documented values (date-time, time, date, duration, email, hostname, uri, ipv4, ipv6, uuid) caveat
G5 pattern present at all caveat
G6 The step declares tools caveat tools_runtime_assessed (issue #311 — was ineligible before it). Strict is ACCEPTED and applies to the step’s tool-call arguments, assessed per tool at runtime; the step’s own OUTPUT stays post-hoc validated (L1), never grammar-constrained. Nudge text: “strict applies to tool-call arguments; tool schemas are third-party, assessed at runtime; step output stays post-hoc validated”. See strict tool-call arguments.
G7’ ANY optional property on an otherwise-eligible schema caveat optional_emissionmeasured: on a real cs1 production schema, strict emitted an optional field in 4 of 24 runs vs 24 of 24 unconstrained (same benchmark, same inputs) — grammar-constrained sampling measurably suppresses optional-field emission on some schema shapes. The remedy: make any field your consumers actually rely on required (a required field is grammar-forced — the model must emit something for it).

The table above is the ANTHROPIC profile. Since issue #313 the verdict function assesses against the provider actually driving the step, and the two profiles genuinely disagree in both directions — see Provider profiles below. Authoring-time surfaces (validate, register, the loader) always use the Anthropic profile: they cannot know which provider a future drive will use.

Post-hoc-only, not silent: every caveat above still gets realm’s normal Ajv enforcement after the fact (a pattern/format/minLength violation is still caught and reaskable) — “caveat” means the GRAMMAR itself can’t guarantee it, not that nothing guarantees it.

Authoring vs runtime — an intentional asymmetry: a --file-loaded workflow (validate, register, a plain loadWorkflowFromFile) REJECTS an ineligible structured_output: strict declaration at load time — a typed loader error naming every remediation. A registered workflow driven at runtime (including one registered via create_workflow’s own raw-JSON path, which never re-runs the YAML loader) instead DEGRADES LOUDLY: strict is never sent, and the attempt’s evidence discloses why (downgrade_reason: 'gate_ineligible') — a run is never silently stranded because a registered definition drifted out of eligibility. This is deliberate, not a gap: authoring-time is where a human can fix the schema; runtime’s job is to keep the run moving.

The fallback ladder (live API failures the static gate could not predict)

Section titled “The fallback ladder (live API failures the static gate could not predict)”

Even an eligible schema can 400 or 503 on a live request (the two undocumented-from-schema classes above). Anthropic’s ladder: any 400 on a request that carried strict: true — or a 503 — drops strict, discloses, and retries once (never a message-text match; the arm keys on HTTP status and whether strict was actually sent). A 503 additionally gets a label: if its message matches Anthropic’s own captured grammar-compilation-unavailable text, the disclosure reads grammar_unavailable; otherwise it reads the generic service_unavailable (fail-safe — a non-matching message never silently degrades to a false-specific label).

OpenAI’s ladder is 400-only. There is no 503-grammar analog on that API, so only a 400 on a strict-carrying request drops strict and retries once; 5xx, transport errors and timeouts propagate untouched and never drop, never stick. That distinction is deliberate: OpenAI documents added latency on the FIRST request compiling a large schema, so a client timeout there must be retried as the transport event it is, never recorded as a schema failure. (Measured at realm’s typical schema scale, the effect was not observable — a novel schema took 834ms against 928ms for a cached one — so this is a note for large schemas, not a general expectation.)

The full downgrade-reason vocabulary an attempt’s evidence (diagnostics.structured_output.downgrade_reason) can carry:

downgrade_reason Meaning
gate_ineligible The runtime (Phase B) verdict was ineligible — strict was never attempted at all.
api_rejected_schema A live 400 on a strict-carrying request.
grammar_unavailable A live 503 whose message matched the captured grammar-compilation text.
service_unavailable A live 503 that did not match — the generic fallback label.
provider_unsupported The configured LLM provider does not support the strict path in question. At the STEP level: it doesn’t implement the strict-aware call path (a third-party --provider-module implementing only the base callStep). In a tool_args entry: it doesn’t place per-tool strict on the wire. Both in-repo tool-capable providers (Anthropic and OpenAI) now do, so at the per-tool level this names third-party --provider-module providers specifically. A conformance suite holds every provider declaring the capability to actually placing it on the wire.
unsupported_context_tools The step declares tools, so its OUTPUT is produced on the tools path, which has no grammar-constrained submit call. Since issue #311 this does not mean strict was unavailable to the step: its tool-call ARGUMENTS may well have been grammar-constrained on the same attempt — see tool_args below.
compat_endpoint (issue #313) The provider is pointed at an OpenAI-compatible endpoint via --base-url and the author has not attested that it enforces strict. Realm declines to send strict rather than send it somewhere that may accept and ignore it. Remedy: --strict-base-url, or a native endpoint.
external_agent The step declared structured_output: strict but was driven by something other than realm agent — e.g. an external agent calling execute_step over MCP directly. realm cannot know whether strict was honored on that path at all; it says so rather than staying silent.

Sticky within a drive (Anthropic): once a step’s strict attempt downgrades (a live 400/503), realm agent remembers it for that step for the rest of the drive session — every later attempt (a retried LLM call, an issue #217 schema-repair iteration) goes out without strict too, never re-attempting it. A non-grammar 503 therefore disables strict for that step for the whole session; the service_unavailable label makes this auditable rather than silent. On OpenAI only a 400 sticks — a 5xx or a timeout leaves the next attempt free to try strict again, because nothing about the schema was in question.

The observables-only vocabulary, and why there is no applied field. Every disclosed field describes what realm did, never what the API did: sent means “realm placed strict: true on the request handed to the SDK” — nothing more. Whether the API (or a proxy in between) actually enforced it is not witnessable from realm’s side. A REJECTING proxy is covered by the same 400 arm as the API itself; a silently-stripping proxy is exactly why the vocabulary never claims “applied” — sent is honest under any SDK/proxy, applied would not be.

  • SDK floor unchanged: @anthropic-ai/sdk >= 0.20.0 (the existing peer-dependency floor). Because the SDK is a consumer-supplied peer dependency, realm can never rely on SDK-version- specific behavior — the fallback ladder is what carries an old or new SDK equally; wire-level strict pass-through under the CURRENT SDK is pinned by test, older versions are expected to behave identically (a stale SDK that rejects strict at all simply routes through the same 400 ladder arm).
  • Grammar-compile latency: the first request against a NEW schema (or a schema/tool-set that changed) pays a one-time grammar-compilation cost; compiled grammars are cached for 24 hours from last use. A workflow’s repeated runs of the same step schema benefit from this cache. The documented compile timeout is 180 seconds — a compile that exceeds it 400s (a class the static gate cannot predict; the fallback ladder is what carries it).
  • Token cost: structured outputs inject an additional system prompt explaining the expected format — a real, if usually small, per-request token cost. required fields are grammar-forced (the model must emit something for each), which is a REAL cost too: each required field measurably grows the grammar’s state space. This is the flip side of the G7’ remedy above — making a field required trades an emission-reliability problem for a small, bounded token cost, which is realm’s own recommended trade, not a free lunch.
  • Reasoning-position note (thinking models): with DEFAULT THINKING enabled, the benchmark that measured the G7’ numbers above found no regression on stored-agreement quality between strict and unconstrained generation, and an optional reasoning-shaped field was emitted identically in both arms — the model’s actual reasoning lives in thinking blocks, ahead of the constrained submit-tool call, so constrained decoding does not truncate it. On a non-thinking (or thinking-disabled) configuration this composition witness does not apply — prefer declaring a reasoning-shaped field required AND ordering it FIRST among the schema’s properties (the API’s own documented contract: required properties are emitted first, in schema order), so it is produced before the answer fields even under constrained decoding.
  • Cloud/self-hosted note: the grammar-compilation latency and 24-hour cache above are Anthropic-side costs, not realm-side — a realm deployment fanning out many DISTINCT schemas (e.g. one per tenant) pays the compile cost more often than one reusing a small, stable schema set; this is a scaling/cost consideration for a cloud operator, not a correctness concern.

An opted-in step’s attempt is disclosed in its evidence entry’s diagnostics.structured_output (realm run inspect); get_run_state (MCP) does not carry per-step evidence at all by design, so an MCP consumer combines the run’s own sealed_by_arm (issue #367 — the recorded fact, read first) + terminal_reason (multi-failure runs list all failed steps) + failed_steps + derived run_phase instead (the same posture issue #304’s completed_with_failed_steps finding already established). realm validate additionally prints an adoption NUDGE on its own informational channel. A default run says it in one graded line — how many steps are ready, how many carry caveats, how many are one change away — and realm validate --explain prints the per-step migration delta behind it, never a bare “eligible”; REALM_NO_NUDGE=1 silences the summary. A step that has already opted in is the exception: its caveat text prints on every run and is never silenced, because advice about DECLARED config is a diagnostic (see cli-commands.md; the nudge never affects the exit code).

Run-health disclosure (issue #316). A run-health structured_output_downgraded finding aggregates every step whose disclosed downgrade_reason is present — surfacing on live runs via get_run_state’s run_health/warnings, and on terminal runs via realm run inspect (see mcp-protocol.md). external_agent is deliberately excluded: it names an MCP-driven attempt realm never made a request for at all, not a degraded one realm itself chose to send unconstrained — reporting it as a finding would fire on every declared step of every non-realm agent-driven run, regardless of whether strict was ever actually attempted. The finding is always informational: even when it fires, the step’s output was still validated post-hoc by realm’s own L1 Ajv + reask loop above — a downgrade narrows the prevention layer, it never removes the safety net.

Expected-fire baseline (issue #311). A step that declares both structured_output: strict and tools fires this finding on EVERY run, permanently, with unsupported_context_tools — its output genuinely is not grammar-constrained. That is a baseline, not a regression signal: alert on the reason list, never on the finding’s presence. The tool-arguments dimension contributes exactly one additional literal, dimension-marked as tool_args:api_rejected_schema, and only for the one actionable event — the API rejecting a tool schema realm had assessed as eligible. Routine outcomes (budget_excluded, eligibility verdicts, 503 labels) are deliberately not surfaced here; they would drown the finding. Per-tool detail lives in realm run inspect/export; MCP pollers see the narrow finding only.

structured_output: strict is not one feature — it is each provider’s own constrained-decoding mechanism, and the rules differ. Realm assesses a schema against the profile of the provider actually driving the step (Anthropic via a strict submit tool; OpenAI via Chat Completions response_format: json_schema). Both profiles were built from EXECUTED probes, not from documentation: the published docs proved wrong in both directions for both vendors.

The differences that matter:

Anthropic OpenAI
Optional properties allowed, with the measured optional_emission caveat ineligible (not_all_required) — every property must be in required
Expressing “may be absent” leave it optional keep it required, widen the type to a null union (['string','null'])
minLength / pattern / format / numeric bounds rejected or silently ignored — caveated supported and enforced
allOf, not, dependent*, if/then/else allOf allowed rejected with a 400
Recursion ($ref) ineligible first-class
Size limits ≤24 optionals, ≤16 unions ≤10 nesting levels, ≤5000 properties, ≤120k schema characters, ≤1000 enum values

The null-union shift, measured. Rewriting optionals as required null-unions is the OpenAI- sanctioned fix, and it is safe — but it does change behaviour, so realm states the numbers rather than reassuring. Over 24 real production inputs on gpt-4o: a load-bearing field stayed filled 24/24 under strict, while a low-salience decorative field shifted to null 21/24 (on gpt-4o-mini, 3/24 versus its own 2/24 unconstrained omission rate). Category agreement was flat across arms. Consumers must treat null as equivalent to absent — which the null-union contract already implies, but which real code often forgets. Numbers are model-labelled because propensity is model-dependent.

Third-party providers. A --provider-module provider is assessed under the Anthropic profile: realm cannot know a module’s strict dialect. If your module targets a different API, that assumption is visible in evidence — every attempt records a provider field, so a module:my-provider.js attempt assessed under Anthropic rules is detectable rather than silent.

Compat endpoints and --strict-base-url (issue #313)

Section titled “Compat endpoints and --strict-base-url (issue #313)”

By default realm never sends strict to an OpenAI-compatible endpoint configured with --base-url. Such endpoints range from full grammar enforcement (vLLM, llama.cpp, LM Studio) to accepting response_format and quietly ignoring it, and no capability-discovery API exists to tell them apart. Rather than risk silent non-enforcement, realm declines and discloses compat_endpoint on every strict-declared attempt.

--strict-base-url is the author’s attestation that a specific endpoint genuinely enforces it. Two honest limits:

  • It is an attestation, not a verification. Realm cannot detect an endpoint that accepts strict and ignores it — that is precisely why the default is off. If you opt in for an endpoint that does not enforce, evidence will read sent: true and be telling the truth about what realm did, while nothing was actually constrained.
  • It lifts strict for opted-in steps only. Steps without structured_output: strict are unaffected and stay on prompt-only JSON enforcement; the flag does not change jsonMode.

The flag warns when supplied without --base-url (it would attest about nothing), cannot be combined with --provider-module, and is ignored — loudly — by the o1 model family, which uses a dedicated provider that always talks to the native endpoint.

Evidence: provider provenance and API error fields (issue #313)

Section titled “Evidence: provider provenance and API error fields (issue #313)”

Every attempt realm agent drives records which provider produced it:

  • provideranthropic · openai · openai-reasoning · module:<basename> for a --provider-module. Engine-synthesized external_agent stamps carry NO provider, by construction: realm did not drive those attempts and has no provider to name.
  • api_param / api_code — the provider’s own machine-readable error fields, captured verbatim beside api_message when a live 400 drove a downgrade. null is preserved and meaningful: OpenAI returns both as null for the model-unsupported class, where the message is prose-only — so api_rejected_schema is a slightly imprecise label for that class, and api_message is where the truth lives. Realm never makes decisions from these fields; they are for the operator.

Zero data retention. OpenAI caches strict schemas server-side, and schemas sent this way are not eligible for zero-data-retention treatment. If a schema’s SHAPE is itself sensitive (field names that reveal an unreleased product, a customer taxonomy), do not opt that step into strict.

Fine-tuned models. They accept a narrower keyword set — a schema that works on a base model can 400 on a fine-tune, which the live 400 arm handles by dropping strict and retrying. One case is NOT covered by any arm: on fine-tuned models, strict is documented as disabled for turns making parallel tool calls. That is SILENT non-enforcement — a 200 with nothing to detect — and it is covered only by the observables-only vocabulary below: sent means realm placed strict on the request, never that the API enforced it.

Declaring structured_output: strict on a step that also declares tools is now accepted. On that step strict targets a different thing: the arguments realm’s model passes to each MCP tool, not the step’s own output. The step output continues to arrive through the unconstrained tool-path submit call and is validated post-hoc (L1) exactly as before — which is why such a step permanently discloses unsupported_context_tools at the step level even while its tools are running under strict. Both statements are true at once; they describe different dimensions.

What strict guarantees here. The grammar constrains what the model may emit for a tool call — which includes the tool NAME it selects, not the argument object alone. Neither this documentation nor realm’s evidence claims “arguments only”.

Per-tool, never transformed. Each declared tool’s inputSchema is assessed with the same G0–G7’ verdict machinery above (G6 excluded — it is the question being answered). Strict is attached only to tools that are compliant as written; the rest ride unconstrained in the same request. realm never rewrites a third-party schema to make it fit: injecting additionalProperties: false into a schema whose server deliberately accepts open objects would change that tool’s meaning.

Expected yield today is low, and honestly so. Measured against 238 real tools across 14 public MCP servers: 8.8% are strict-attachable as written, and 0.5% excluding a single server (playwright) whose pass is an accident of its schema converter’s default settings. GitHub’s 116-tool server: 0%. The dominant blockers are a missing explicit additionalProperties: false (near-universal — 192 of 193 measured) and minimum/maximum on pagination parameters. The ceiling rises only when upstream servers publish stricter schemas (watched in issue #344) or a future realm version offers opt-in injection for servers that provably ignore unknown keys.

Enforcement falls to the MCP server. There is no Ajv on the tools path — realm does not post-hoc validate tool arguments. So for any tool riding unconstrained, and for any caveat-class keyword on a strict-attached tool, the argument contract is not grammar-enforced and NOT post-hoc enforced by realm — enforcement falls to the MCP server at call time. This is the one place where a caveat means genuinely nobody in realm is checking.

The explicitly-open-schema class. Some servers set additionalProperties: true on purpose — Notion’s tools are the clearest example, modelling deliberately open property bags. These are ineligible and must stay so: they are not oversights, and “fixing” them would silently narrow a documented API.

Budgets are per-provider, best-effort, and the API is always the acceptor of record.

Anthropic caps a request at 20 strict tools and 24 summed optional properties across them. realm walks the declared tools in order, attaches strict while both limits hold (inclusive — landing exactly on a limit fits), and SKIPS a tool that would exceed one while continuing with the rest (a skipped tool reports budget_excluded). Ineligible tools consume zero budget. Reorder your tools: list to change which tools win the budget.

OpenAI publishes no strict-tool count and no optional budget — 128 strict tools in a single request were executed successfully — so realm runs no budget walk there and marks EVERY eligible tool; budget_excluded is never reported under that profile. The only ceiling is the generic 128-element tools array cap, which authoring hits long before this ever would.

Either way the arithmetic is best-effort: if the API rejects the request anyway, that 400 is the acceptor of record — realm drops strict for the step’s tools, retries the turn once unconstrained, and discloses what happened.

Tool descriptions matter. Grammar quality is description-sensitive, and realm’s MCP client defaults a missing tool description to the empty string. A server that publishes tools without descriptions gives the model less to work with, strict or not.

Failure handling is status-only. A 400 on a strict-carrying request drops strict for that step’s tools and is sticky for the rest of the drive (the same schemas would be rejected again). On Anthropic, a 503 additionally drops strict for that attempt only and is deliberately not sticky — transient overload must not disable the feature for a whole session. The OpenAI tools fork is 400-only: it has no 503-grammar analog, so 5xx responses, transport errors and timeouts propagate untouched and never drop or stick (a slow first call compiling a large schema is a transport event, not a schema failure). realm never parses error message text to make these decisions; it keys on HTTP status, with the message captured verbatim as evidence.

Disclosure. The attempt’s evidence carries a tool_args block listing every DECLARED tool with strict_requested, strict_sent (the attempt’s final posture), and its reasons/caveats, plus a dropped_mid_attempt record when a live 400/503 dropped strict part-way. Per-tool detail is visible via realm run inspect/realm run export. Run-health surfaces only the one actionable class, as the dimension-marked tool_args:api_rejected_schema.

Provider support. Attaching per-tool strict requires a provider that places the marker on its own wire format — Anthropic on the tool object, OpenAI inside function. Both in-repo tool-capable providers do, and each tool schema is assessed under ITS provider’s profile (so the same tool can be eligible on one and not the other — see Provider profiles).

A third-party --provider-module provider does not: no tool carries strict, no per-tool eligibility is assessed (running the assessment would report one provider’s rules at a provider that could never send strict anyway), and every tool_args entry reports provider_unsupported. The step still runs normally; only the tool-arguments dimension is inert.

Separately, a compat endpoint without --strict-base-url reports compat_endpoint per tool. The two literals never conflate: provider_unsupported means this provider cannot send the marker, compat_endpoint means it can but realm chose not to have it.

examples/09-webhook-pr-review/workflow.yaml’s agent steps declare tools and deliberately do NOT declare structured_output. Measured across 262 tools from 17 public MCP servers: 20.6% are strict-attachable as written under the Anthropic profile and 5.3% under OpenAI’s — and 0% on GitHub’s own 116-tool server under either. Opting in is honest either way (every tool reports why it was or was not constrained), but on most public servers today it attaches strict to few tools or none.


$settlement namespace (fallback-provenance branching)

Section titled “$settlement namespace (fallback-provenance branching)”

Issue #220 PR-3. $settlement is a reserved, engine-minted evaluation-root namespace exposing — for every step that has SETTLED (completed or failed) — whether it settled via its own submission or via a declared validation_exhaustion.mode: 'default' fallback:

$settlement.<step>.settled_by_default → boolean
$settlement.<step>.validation_rejections → integer (count of schema rejections before settling)
$settlement.<step>.failed → boolean (issue #305 — did this step FAIL?)

An entry exists only for a step in completed_steps ∪ failed_steps — a skipped step, a still-in-progress step, or a step whose only evidence is a non-settling snapshot (e.g. an in-flight gate preview) has no $settlement entry at all; absence is never a third status. For a settled step that never used mode: 'default', settled_by_default is explicitly false (never merely absent) and validation_rejections is 0 if it never accrued any.

failed (issue #305) is true for a step in failed_steps and false for one in completed_steps. Together with absence, the three settlement outcomes are distinguishable:

Outcome How to read it
Failed failed: true
Completed on its declared default failed: false and settled_by_default: true
Skipped no entry at all — on when, test with $settlement.<step>.failed == null (see the surface caveat below)

It is deliberately STATUS only — never a message or a cause. Failure detail stays on the run record (realm run inspect), which is where every surveyed orchestrator keeps it: per-unit status is the in-band floor, detail in a declarative payload is shipped nowhere. failed also clears on resume, since a resumed step leaves failed_steps — it reports the run’s current truth, not a permanent brand.

$settlement is available on every evaluation surface, but the ROOT it hangs off differs, exactly like every other evidence reference on that surface:

Surface Spelling
when $settlement.<step>.settled_by_default
abort_unless (guard steps) $settlement.<step>.settled_by_default
preconditions $settlement.<step>.settled_by_default
input_map context.resources.$settlement.<step>.settled_by_default
Template filters ({{ }}, incl. gate.message) context.resources.$settlement.<step>.settled_by_default
execution: finalizer (handler code) ctx.resources['$settlement'][<step>].failednot a declarative surface; see below

The root is shared; the literal parser is not (correction of a v0.38.0 documentation defect). The table above is about where $settlement hangs off — that part is genuinely uniform. What is NOT uniform is how each surface parses a right-hand-side literal. Only when understands the bare null token, so $settlement.<step>.failed == null is a when-only absence test. On preconditions and abort_unless the evaluator has no null branch and the right-hand side becomes the string 'null', which is never equal to anything the namespace holds. The consequences differ by surface and none of them is what the author meant: on preconditions the leaf is universally false, so the step never settles and the run wedges; on abort_unless the guard aborts every run; and against a skipped dependency it raises a resolution error. Test for absence on when, or restructure so the question is asked where it can be answered.

route:
execution: auto
depends_on: [classify]
when: ['$settlement.classify.settled_by_default == false']
approve:
execution: guard
depends_on: [classify]
abort_unless: ['$settlement.classify.settled_by_default == false']
notify:
execution: agent
depends_on: [classify]
input_map:
was_fallback: 'context.resources.$settlement.classify.settled_by_default'

Routing a cleanup step on which dep failed (issue #305)

Section titled “Routing a cleanup step on which dep failed (issue #305)”

trigger_rule: one_failed already routes on “some dependency failed”. What $settlement adds is which one — the thing a multi-dependency cleanup actually needs:

cleanup:
execution: auto
depends_on: [extract, transform]
trigger_rule: all_done # LOAD-BEARING — see the caveat below
when: ['$settlement.extract.failed == true']
handler: compensate_extract

The trigger_rule is not optional here — and since issue #362 the loader enforces it. The trigger gate is evaluated BEFORE when, so under the default all_success (or none_failed) the step is marked unsatisfiable the moment a dependency fails: your condition never runs and the compensation silently never fires. That combination is now a load error naming the step, the condition, the effective rule, and the rules that would actually run it.

Use all_done or one_failed — those are the two that fire in every world where a dependency fails, at any dependency count.

Correction of a v0.38.0 documentation defect: this paragraph previously said “use all_done (or all_failed)”. For the two-dependency example above that advice is wrongall_failed requires EVERY dependency to fail, so it would not fire when only extract failed, and the compensation would still never run. all_failed is a valid remedy only when the step has exactly one distinct dependency. one_failed does reach the failure case at any count, with the tradeoff that a clean run then skips the step by TRIGGER RULE rather than by your condition, which makes the two paths harder to tell apart when debugging.

As with any $settlement reference on when, the one-hop rule below applies: extract must be in this step’s depends_on.

The dead-failure-condition load error (issue #362)

Section titled “The dead-failure-condition load error (issue #362)”

A condition that can never be true is refused at load time:

Step 'cleanup': 'when' condition "$settlement.extract.failed == true" can never be true —
under the default 'all_success' trigger rule, 'extract' can never be in failed_steps when this
step is evaluated (…). To run this step when 'extract' fails, set trigger_rule to one of:
all_done, one_failed, all_failed.

It fires when all of these hold: the leaf is $settlement.<dep>.failed == true (or the bare $settlement.<dep>.failed, which coerces the same way), <dep> is a declared dependency, and the step’s effective trigger rule is all_success or none_failed — the two rules that structurally exclude a failed dependency. Nothing heuristic: those two rules each carry an explicit “no dependency in failed_steps” conjunct, so a satisfied gate proves the condition false.

The remedy is computed for your step, not copied from a list. all_done and one_failed always work. all_failed is offered only when the step has exactly one distinct dependency — with two or more it requires them ALL to fail, so recommending it would hand you a workflow that loads cleanly and still never runs. one_success is never a remedy.

Guards are different, and the error says so. trigger_rule is not a valid field on execution: guard, so a guard always runs under all_success and there is no rule to widen. A guard runs only when its dependencies succeeded; for work that must happen after a failure, use an execution: finalizer step. That is a v1 scope narrowing rather than an architectural rule — issue #366 carries the question of widening it.

Deliberately not covered in v1 (issue #364): one_success at a single dependency, which is dead for a subtler reason, and the == false mirror class. Those are decidable too, but each rests on a weaker argument and deserves its own decision rather than being folded in here.

Finalizers read the marker in handler code (issue #305)

Section titled “Finalizers read the marker in handler code (issue #305)”

A step with execution: finalizer cannot use when, depends_on, or input_map — all three are refused at load time on finalizers. A finalizer therefore selects whether it runs with on_outcome, and branches on what happened inside its handler:

export default {
id: 'compensate',
async execute(inputs, ctx) {
const settlement = ctx.resources['$settlement'] ?? {};
if (settlement['extract']?.failed === true) {
// compensate the extract step specifically
}
},
};

This is a v1 scope decision, not an architectural limit — the loader’s own note reads “handler-only in v1”, and the documented deadlock rationale for finalizer dependencies concerns only the reverse direction (a domain step depending on a finalizer). Finalizers branch in handler code today; widening this is under discussion in issue #360.

One ordering note: finalizers settle into failed_steps too, so a finalizer that failed earlier in the drain appears with failed: true to later-ranked finalizers. A finalizer’s view reflects the drain up to its own rank.

One-hop rule (load-time enforced on when/abort_unless/preconditions)

Section titled “One-hop rule (load-time enforced on when/abort_unless/preconditions)”

<step> in a $settlement.<step>.… reference must be a direct dependency of the referencing step (the same one-hop rule when already enforces for ordinary step references) — a $settlement.<step> where <step> is not in depends_on is load-refused, on all three of when/abort_unless/preconditions. input_map and template filters do not get this check — see the residual below.

Per-surface consequence disparity (a bad FIELD name, e.g. a typo’d settled_by_defalut)

Section titled “Per-surface consequence disparity (a bad FIELD name, e.g. a typo’d settled_by_defalut)”

Only the <step> segment is load-time-validated (the one-hop rule above); a typo in the FIELD segment is never load-refused anywhere, and its RUNTIME consequence differs by surface. (One narrow exception since issue #362: a correctly spelled failed field is load-refused when the condition it forms can never be true under the step’s trigger rule — that check keys on the exact three-segment $settlement.<dep>.failed shape, so a typo’d field falls outside it and still behaves as below.)

Surface Consequence of an unresolvable field
when The leaf resolves undefined → traced as lhs_present: false in skip_details (visible)
abort_unless (guard) Unresolvable path → resolution_error → the guard step FAILS the run
preconditions Unresolvable path → the precondition never passes → the step blocks forever
input_map / templates Resolves to undefined silently — no trace, no refusal

Named residual: input_map has no load-time reference validation at all (this predates $settlement and is unchanged by it) — a typo’d $settlement path there is indistinguishable, at load time, from a correctly-spelled one that simply hasn’t settled yet. This is a known, accepted gap, not a bug; do not expect input_map to catch a $settlement typo the way when does.


v0.14: each services: entry is validated against a strict schema — the key set is closed (adapter, trust, rate_limit). auth.token_from was removed: credentials bind at adapter CONSTRUCTION time in the deployment manifest (realm.yaml), never in workflow YAML.

services:
source:
adapter: google_docs
trust: engine_delivered
Field Type Description
adapter string Name of a registered ServiceAdapter (built-in, extension, or manifest).
trust 'engine_delivered' | 'engine_managed' | 'agent_provided' Service trust level — a different key from a step’s own trust: (see Trust levels).

Current limitation — content injection: The adapter response is injected in full into context.resources.<step_name> and flows into every subsequent agent step’s prompt context. For large files (logs, lengthy documents, large JSON) this consumes significant context budget. Keep service-read files small — under roughly 10 KB.

Value Description
engine_delivered Service response is injected directly into evidence. The agent cannot see or alter it.
engine_managed The engine manages the service call; the agent provides input parameters.
agent_provided The agent is responsible for the service interaction.

For full configuration reference, supported operations, and response shapes for the built-in adapters (FileSystemAdapter, GitHubAdapter, GenericHttpAdapter), see the Built-in Service Adapters Reference.


Step templates are reusable named step groups declared in a top-level templates: block. They are resolved at load time — there is zero runtime overhead and no new files on disk. Templates eliminate copy-paste in workflows that repeat the same step pattern with different service names, prefixes, or agent descriptions.

templates:
extract_and_record:
params:
service_name:
required: true
agent_description:
default: 'Review the extracted content.'
steps:
extract:
description: 'Extract content from {{ service_name }}'
execution: auto
depends_on: []
uses_service: '{{ service_name }}'
operation: read
review:
description: '{{ agent_description }}'
execution: agent
depends_on: ['{{ prefix }}_extract']
steps:
invoice_check:
use_template: extract_and_record
prefix: invoice
params:
service_name: invoices
agent_description: 'Review the extracted invoice for anomalies.'

prefix is mandatory when use_template is present. It is used both for step ID generation (invoice_extract, invoice_review) and as the {{ prefix }} placeholder in all template step strings. The parent key (invoice_check) is discarded after expansion.

Field Type Description
required boolean If true, the caller must supply this param. Missing → load error.
default string Used when the caller does not supply the param.

Unknown params passed at the call site are silently ignored (forward compatibility).

id: document-pipeline
name: Document Pipeline
version: 1
services:
documents:
adapter: filesystem
trust: engine_delivered
templates:
fetch_and_review:
params:
service_name:
required: true
agent_description:
default: 'Review the document.'
steps:
fetch:
description: 'Fetch from {{ service_name }}'
execution: auto
depends_on: []
uses_service: '{{ service_name }}'
operation: read
input_map:
path: run.params.path
review:
description: '{{ agent_description }}'
execution: agent
depends_on: ['{{ prefix }}_fetch']
steps:
doc_pipeline:
use_template: fetch_and_review
prefix: doc
params:
service_name: documents
agent_description: 'Review the fetched document for completeness.'

This expands to two concrete steps: doc_fetch and doc_review.


An execution: agent step can declare a reusable persona via the agent_profile field. The persona is defined in a Markdown file and delivered verbatim to the agent at step entry.

profiles_dir: profiles # relative to workflow YAML; defaults to profiles/
steps:
review_security:
execution: agent
agent_profile: security-reviewer # reads profiles/security-reviewer.md

Profile content is resolved at registration time, not at runtime. When you run realm workflow register, the loader reads every referenced .md file from profiles_dir, computes a SHA-256 hash, and bakes both the content and hash into the stored workflow definition at ~/.realm/workflows/<id>.json. After registration the profiles/ directory on disk is not consulted again.

Consequences of this model:

  • Editing a profile file has no effect until you re-run realm workflow register.
  • Multiple steps referencing the same profile name are resolved once — the file is read and hashed a single time.
  • If any referenced file is missing at registration time, the command fails immediately and includes the expected file path in the error message.

When a consumer calls get_workflow_protocol, the full profile content is included in the step’s agent_profile_instructions field. No file system access is needed at runtime — the content is served from the stored definition over MCP.

The profile name and its SHA-256 hash are recorded in the evidence snapshot for every step that ran with a profile. realm run inspect displays them as [profile: <name>] annotations.


The prompt field supports template references resolved at runtime:

Syntax Resolves to
{{ context.resources.STEP.FIELD }} Value of FIELD in the evidence output of STEP
{{ run.params.FIELD }} Value of FIELD in the run’s params
{{ workflow.context.NAME }} Content of the named workflow context entry, wrapped per context_wrapper
{{ workflow.context.NAME.raw }} Raw content of the named workflow context entry, no wrapping

Unresolved references are left as literal strings.


The workflow_context section declares named files that are loaded once at run start and made available in every step prompt. This is the correct place for standing workflow configuration — canonical schemas, output format rules, domain glossaries, brand guidelines — anything that applies to multiple steps without being specific to one run.

workflow_context:
canonical_schema:
source:
path: ./schema.json # relative to the workflow YAML file
description: 'Field definitions and output rules' # optional
brand_guidelines:
source:
path: ./guidelines.md
context_wrapper: xml # optional; default is xml

In a step prompt:

steps:
extract_fields:
execution: agent
depends_on: []
prompt: |
Extract the required fields using the schema below.
{{ workflow.context.canonical_schema }}
Source document: {{ context.resources.fetch_doc.text }}

With context_wrapper: xml (the default), {{ workflow.context.canonical_schema }} resolves to:

<canonical_schema>
{file content}
</canonical_schema>

For inline references where block-level wrapping would be awkward, use .raw:

prompt: |
The allowed output format is {{ workflow.context.output_format.raw }}.
Apply it to every field you extract.
Field Type Required Description
source.path string Yes File path relative to the workflow YAML. Resolved to absolute at registration time.
description string No Human-readable description of what the file contains.
Value Result for {{ workflow.context.NAME }}
xml <NAME>\n{content}\n</NAME> (default)
brackets [NAME]\n{content}\n[/NAME]
none Raw content, same as .raw

{{ workflow.context.NAME.raw }} always returns raw content regardless of context_wrapper.

Entry names must match [\w.]+ (letters, digits, underscores, and dots — no hyphens). Names ending in .raw are rejected because .raw is the reserved accessor suffix.

Files are read on the first execute_step call for each run — not at registration time. The content is snapshotted into the run record under workflow_context_snapshots, separate from step evidence. The snapshot is reused for all subsequent steps in the same run. Editing a file on disk takes effect at the next run start without re-registration.

If a file cannot be read (missing path, permission error), an error snapshot is recorded and execution continues. The template reference is left unresolved in the delivered prompt.

If schema.json is present in the workflow directory and no workflow_context.schema entry is explicitly declared, the loader automatically registers it as workflow.context.schema. This makes it possible to place a JSON Schema next to workflow.yaml with zero extra configuration.

realm workflow register prints a warning when the same context entry is referenced in more than half of the agent step prompts in the workflow. This is advisory — registration succeeds regardless.


protocol:
quick_start: "Call start_run with workflow_id 'my-workflow'..."
rules:
- 'Always confirm with the user before writing to the target system.'

quick_start overrides the generated instructions paragraph in get_workflow_protocol. rules replaces the default rule set entirely — include the defaults if you still want them.


Two handlers are available in every Realm instance without registration. Declare them with handler: on any execution: auto step, and configure them with a config: block.

Verifies that AI-extracted quotes appear verbatim in a source document.

Config key Required Default Description
source_step Yes Name of the prior step that produced the source text.
source_field No "text" Field in the source step’s output holding the source text.
quote_field No "verbatim_quote" Field in each candidate object holding the quote to verify.

Inputs: candidates — array of objects, each containing a quote_field value.

Output: { accepted, rejected, accepted_count, rejected_count, candidates_found }

candidates_found (accepted_count + rejected_count) is the key diagnostic: it distinguishes “nothing was extracted” from “all extracted were invalid”.

validate_quotes:
description: 'Verify extracted quotes appear verbatim in the source document.'
execution: auto
handler: validate_verbatim_quotes
depends_on: [extract_quotes]
config:
source_step: fetch_document
source_field: text

Reads a field from a prior step’s output and compares it against a pattern. Use this as a guard to verify that a fetched resource belongs to the expected entity.

Config key Required Default Description
source_step Yes Name of the prior step that produced the value.
source_field Yes Field in that step’s output to read.
pattern Yes Value or pattern to compare against.
mode No "exact" "exact", "prefix", or "regex".

Output: { matched, value, pattern, mode }

This handler never throws on mismatchmatched: false is a valid outcome that the workflow handles via preconditions on downstream steps.

verify_repo:
description: 'Verify the fetched diff belongs to the expected repository.'
execution: auto
handler: validate_field_match
depends_on: [fetch_diff]
config:
source_step: fetch_diff
source_field: repo_full_name
pattern: 'myorg/.*'
mode: regex

For handler authoring details, interface signatures, primitives, and registration patterns, see Handler Authoring Reference.


Defines external MCP servers that steps may call tools on. Each server has a unique id. Step tool declarations reference server entries via server_id:tool_name in the tools field.

A step that declares tools requires an mcp_servers block. Without one there is no server to offer the tools from, so the declaration can never be satisfied — the workflow is refused at load (issue #338). Previously it loaded and ran with the tools simply never offered, and nothing said so. Either define the block or remove the tools declaration; an empty tools: [] declares nothing and is unaffected.

mcp_servers:
- id: github # required; unique within this workflow
transport: stdio # required; only 'stdio' is supported
command: npx # required for stdio transport
args:
- -y
- '@modelcontextprotocol/server-github'
env:
GITHUB_TOKEN: '${GITHUB_TOKEN}' # ${VAR} is expanded from process.env at connect time
Field Required Description
id Yes Unique server identifier within this workflow. Used in tools field references.
transport Yes Transport type. Currently only stdio is supported.
command Yes Executable to launch (e.g. npx, node, absolute path).
args No Arguments passed to command.
env No Environment variables for the server process. Values support ${VAR} expansion.

env values support ${VAR} substitution resolved from process.env at connect time. An unresolved variable causes the run to fail with MCP_CONNECTION_FAILED at the point where the first tool call for that server is attempted.


The optional top-level trigger block makes a workflow reachable over HTTP: realm listen loads every workflow with a trigger block, builds a route table, and turns each verified inbound webhook into a run. The whole block is validated at register time (fail-closed — unknown/typo’d keys are rejected, every auth mode is a closed field set).

# A Gorgias-style shared_secret trigger.
trigger:
type: webhook
path: /gorgias-tickets # optional; defaults to /<workflow-id>
auth:
mode: shared_secret
header: Authorization # request header carrying the token (matched case-insensitively)
secret_from: GORGIAS_WEBHOOK_TOKEN # env var holding the EXACT expected header value
filter:
all:
- { path: body.type, value: [ticket-created, ticket-message-created] }
dedup:
id_from: body.id # dot-path; root is { headers, body }
params_map:
ticket_id: body.id
Field Type Required Description
type webhook Yes Only webhook is supported.
path string No URL path realm listen mounts this workflow at. Default /<workflow-id>. A collision across workflows is a startup error.
auth object Yes Verification config, discriminated on mode (see below).
filter object No Optional pre-dispatch match. See filter.
dedup object | false No Duplicate-delivery suppression (default on). See dedup.
params_map object No Maps run params from the payload. See params_map.

auth.mode selects the verification model. Each mode is a closed field set — a field belonging to another mode (e.g. algorithm on a github auth) is rejected at load. Verification runs before filtering and dedup; a failure returns 403.

Mode Required fields Optional fields Verification
shared_secret header, secret_from The request header header must equal env[secret_from] (exact, timing-safe). The Gorgias model.
github secret_from X-Hub-Signature-256 HMAC-SHA256 of the raw body against env[secret_from].
stripe secret_from max_age_seconds (integer ≥ 1) Stripe-Signature timestamped HMAC; max_age_seconds bounds the replay window.
hmac secret_from, header algorithm (sha1|sha256|sha512), encoding (hex|base64), timestamp_header, max_age_seconds (≥ 1) Generic HMAC of the raw body in header; algorithm defaults sha256, encoding defaults hex.
none Verification disabled. Trusted-network/localhost only (discouraged); realm listen warns at startup.

All string fields (header, secret_from, timestamp_header) must be non-empty. secret_from is the name of an environment variable, resolved at realm listen startup (a missing var is a startup error, not a per-request failure).

Optional. { all: [ … ] } — 1 to 8 conditions combined with AND; the request is dispatched only if every condition matches, otherwise realm listen responds 200 { status: "ignored" }. A shorthand single condition (a bare { header|path, value }) is normalised to { all: [ … ] } at load.

Each condition has exactly one of:

  • header — a request header name (matched against the lowercased header map), or
  • path — a dot-path resolved against { headers, body },

plus value — a non-empty string, or a non-empty array of non-empty strings (matches if the resolved value equals the string, or is one of the array entries).

Optional, on by default. Set dedup: false to disable. Otherwise an object:

Field Type Required Description
id_from string (dot-path) Yes Non-empty dot-path to the unique event ID, resolved against { headers, body } (e.g. body.id).
ttl_minutes integer No Dedup window, 110080 (7 days). Default 60.
on_missing_id skip | reject No When id_from resolves to nothing: skip (default — proceed without dedup) or reject (400).

A duplicate within the window returns 200 { status: "deduplicated" }. Dedup is at-least-once (best-effort in-flight store + the run store’s idempotency key as the cross-restart backstop).

Optional Record<string, string> — run-param name → dot-path into { headers, body }. Values must be non-empty strings. The dot-path root key is headers (plural) and body; a singular header.… silently resolves to nothing. Extracted params are validated against the workflow’s params_schema before the run is created (invalid → 400).

Note: the trigger block configures how a webhook reaches the workflow; it does not change the workflow’s steps. It has no effect unless the workflow is served by realm listen.


workflow.yaml
extensions: ../../dist/registry.js # string | string[] — RELATIVE paths only

Declares the ES module(s) providing this workflow’s custom adapters, step handlers, and processors. Every step-executing or config-validating entry point (run, agent, listen children, serve, mcp, test, validate, register, watch) resolves the same declaration identically — no bespoke MCP wrappers or per-command wiring.

Contract:

  • string | string[] — one or more module paths. Empty strings and empty arrays are rejected.
  • Paths are relative to the workflow directory — absolute paths are rejected at load time.
  • Requires file-based loading: registering the same YAML from a string (or via the MCP create_workflow tool) is a hard error — there is no directory context to resolve against.
  • Each module’s default export is a declarative object (see Project extensions guide for the full contract):
registry.js
export default {
adapters: { gorgias: new GorgiasAdapter('gorgias', { ... }) },
handlers: { check_offer_phrase_handler: myHandler },
processors: { normalize_offer: myProcessor },
};

Trust-root containment: at registration time the loader records the workflow directory (source_dir) and its trust root — the nearest ancestor directory containing package.json or .git (falling back to the workflow directory itself). Declared paths resolve against the workflow directory and must land (realpath-resolved, symlinks included) inside the trust root; anything escaping it is refused. This keeps declarations like ../../dist/registry.js working in a normal project layout while making the workflow store unusable as a load-arbitrary-code vector.

TypeScript: compiled JS is the default. A declared .ts/.mts path loads through jiti resolved from your project’s own node_modules — install it there (npm install --save-dev jiti) or compile to JS. jiti is never resolved from the Realm CLI install.

See the full Project extensions guide for the module contract, collision/precedence rules, and the trust model.