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.
Top-level fields
Section titled “Top-level fields”| 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. |
Step fields
Section titled “Step fields”| 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. |
input_map values and $literal
Section titled “input_map values and $literal”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
$literalnode —{ $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.$literalis 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_idA $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.
The $ prefix is reserved (issue #287)
Section titled “The $ prefix is reserved (issue #287)”$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 time —
loadWorkflowFromFile/validate/registerreject 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 time —
resolveInputMapNodethrowsINPUT_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 directiveNote 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.
Execution modes
Section titled “Execution modes”execution: agent
Section titled “execution: agent”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.
Schema blocks are validated at load
Section titled “Schema blocks are validated at load”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.
execution: auto
Section titled “execution: auto”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.
The bare auto step
Section titled “The bare auto step”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_serviceset): 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 innext_actionsand requires an explicitexecute_stepcall carrying agent-authored params; a bareautostep is never surfaced innext_actionsand settles automatically as part of the auto-chain.
This is intentional, not an authoring error.
execution: guard
Section titled “execution: guard”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_phasebecomes'aborted', andget_run_stateincludesabort_contextwith the evaluated conditions. No further steps execute. - If a path in
abort_unlesscannot be resolved (absent evidence field): the guard fails withGUARD_RESOLUTION_ERROR,run_phasebecomes'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.
execution: finalizer
Section titled “execution: finalizer”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:
handleris required — v1 is handler-only (nouses_service, no agent dispatch).trustmust 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 recognizedtrustvalue at all (see Trust levels; issue #508).on_outcomeis 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 acompleteseal —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 intocompleted_with_failed_stepsexplicitly; it is never implied byfailor bycompletealone.Second-epoch runs: the
completed_with_failed_stepspredicate isfailed_steps.length > 0at 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 itsfail-triggered finalizer itself fail (an unresumable step, so it stays infailed_stepsforever), gets resumed, and then completes on its second epoch still firescompleted_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 abandonnever runs finalizers, in any epoch.abortis the graceful terminal path and runs finalizers normally;abandonis an operator kill switch for a run that cannot be aborted normally, and stays a kill — declared finalizers (if any) do not run. Both theabandon_runMCP tool andrealm run abandondisclose this on every successful abandon.
Trust levels
Section titled “Trust levels”| 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).
Step dependencies (depends_on)
Section titled “Step dependencies (depends_on)”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 completeThe 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.
trigger_rule
Section titled “trigger_rule”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. |
Recovery pattern
Section titled “Recovery pattern”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 failsSkip propagation
Section titled “Skip propagation”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.
when condition
Section titled “when condition”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/!= nullare presence tests (loose null) —== nullis true for a missing or present-nullvalue;!= nullis true only for a present non-null value.- relational ops (
> < >= <=) → false (nonull → 0coercion). - 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; butx.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.
Shadow mode via run.params
Section titled “Shadow mode via run.params”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).
Preconditions
Section titled “Preconditions”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
Section titled “Gate message”gate.message is a developer-authored template string shown to the human reviewer when a gate opens. It is distinct from prompt — prompt 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):
gate.messageresolved → used asgate.displaystep.promptresolved → used asgate.display(existing behavior, unchanged)- Neither present →
gate.displayabsent (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.
Authoring guidelines
Section titled “Authoring guidelines”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 approvedmessage: | Review this? Confirm to proceed or reject to cancel.
# BAD: dumps the content body — gate.message is a summary, not the contentmessage: | Summary: {{ context.resources.write_summary.full_summary }}
# BAD: includes response instructions — the runtime appends thesemessage: | 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 readingmessage: | 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’sinput_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
resolution_messages
Section titled “resolution_messages”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 FunctionsTimeoutSeconds. - The notify clock (
reminder_seconds/reminder_max, plus the CLI operator’s ownreminderIntervalMs/escalationThresholdMsconfig) 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 mode — timeout_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.
Mint-freeze semantics
Section titled “Mint-freeze semantics”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 changeddefault_choicecould 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 (nogate_expired_awaiting_drivefinding, since there is noexpires_atto compare against), reminder-silent (the operator’s ownreminderIntervalMsconfig 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 withgate.timeout_secondsbut driven by a binary older than issue #291 has that key silently ignored — the gate behaves as if untimed.
The fallback ladder (never a strand)
Section titled “The fallback ladder (never a strand)”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 vocabulary
Section titled “Realm ↔ canon vocabulary”| 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.
Step display
Section titled “Step display”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 }}Short-path syntax
Section titled “Short-path syntax”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.
Gate fallback
Section titled “Gate fallback”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 absentFor gate steps that need filters or cross-step references in the decision card, use
gate.message — not display:.
Template filters
Section titled “Template filters”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.
Tier 1 filters
Section titled “Tier 1 filters”| 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.
capitalizeuppercases 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.
Tier 2 filters
Section titled “Tier 2 filters”| 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
dateoutput is in UTC.timeZone: 'UTC'is used in everyIntl.DateTimeFormatcall — output is deterministic regardless of server timezone.
splitis the only Tier 2 filter that changes the value type fromstringtostring[]. 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: trueis 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 thanidempotentalone, 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_timeoutandtotal_timeout_secondsare 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 anagent/guardstep draws an advisory (RETRY_INERT_NON_AUTO), since the built-in dispatch path never throws for those steps and so never consumes the block.
How the loader decides what to refuse
Section titled “How the loader decides what to refuse”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_000backoffMargin = Σ n=0..(maxRetries−1) min(0.5 × 2ⁿ, 8) seconds = 1.5s at maxRetries 2The + 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:
- The unbounded post-header hang — headers arrive, the body never finishes, and a request-level timeout that has already been satisfied never fires again.
- Unbounded server-directed sleeping — a
Retry-Afterthe 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-Timeoutheader 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: 3Every 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 agentcoherence warning: when--schema-retries’s own in-drive repair budget (schemaRetries + 1attempts) 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_exhaustionis 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 eligibility gate
Section titled “The eligibility gate”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_emission — measured: 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.
Operational notes
Section titled “Operational notes”- 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-levelstrictpass-through under the CURRENT SDK is pinned by test, older versions are expected to behave identically (a stale SDK that rejectsstrictat all simply routes through the same400ladder 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.
requiredfields 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 fieldrequiredtrades 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
requiredAND 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.
Disclosure
Section titled “Disclosure”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.
Provider profiles (issue #313)
Section titled “Provider profiles (issue #313)”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
strictand ignores it — that is precisely why the default is off. If you opt in for an endpoint that does not enforce, evidence will readsent: trueand 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: strictare unaffected and stay on prompt-only JSON enforcement; the flag does not changejsonMode.
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:
provider—anthropic·openai·openai-reasoning·module:<basename>for a--provider-module. Engine-synthesizedexternal_agentstamps 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 besideapi_messagewhen a live 400 drove a downgrade.nullis preserved and meaningful: OpenAI returns both as null for the model-unsupported class, where the message is prose-only — soapi_rejected_schemais a slightly imprecise label for that class, andapi_messageis 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.
Strict tool-call arguments (issue #311)
Section titled “Strict tool-call arguments (issue #311)”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.
Per-root spelling
Section titled “Per-root spelling”$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>].failed — not 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_extractThe 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 wrong — all_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 thisstep 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.
Services
Section titled “Services”v0.14: each
services:entry is validated against a strict schema — the key set is closed (adapter,trust,rate_limit).auth.token_fromwas 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.
Service trust levels
Section titled “Service trust levels”| 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
Section titled “Step Templates”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.
Declaring a template
Section titled “Declaring a template”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']Using a template
Section titled “Using a template”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.
Param declaration reference
Section titled “Param declaration reference”| 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).
Complete end-to-end example
Section titled “Complete end-to-end example”id: document-pipelinename: Document Pipelineversion: 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.
Agent profiles
Section titled “Agent profiles”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.mdRegistration lifecycle
Section titled “Registration lifecycle”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.
Runtime delivery
Section titled “Runtime delivery”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.
Prompt templates
Section titled “Prompt templates”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.
Workflow context
Section titled “Workflow context”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 xmlIn 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.Entry fields
Section titled “Entry fields”| 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. |
context_wrapper values
Section titled “context_wrapper values”| 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.
Naming constraints
Section titled “Naming constraints”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.
How context is loaded
Section titled “How context is loaded”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.
schema.json auto-registration
Section titled “schema.json auto-registration”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.
Lint warning
Section titled “Lint warning”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 customisation
Section titled “Protocol customisation”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.
Built-in handlers
Section titled “Built-in handlers”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.
validate_verbatim_quotes
Section titled “validate_verbatim_quotes”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: textvalidate_field_match
Section titled “validate_field_match”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 mismatch — matched: 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: regexFor handler authoring details, interface signatures, primitives, and registration patterns, see Handler Authoring Reference.
MCP servers
Section titled “MCP servers”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.
Webhook trigger
Section titled “Webhook trigger”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).
filter
Section titled “filter”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), orpath— 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, 1–10080 (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).
params_map
Section titled “params_map”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
triggerblock configures how a webhook reaches the workflow; it does not change the workflow’s steps. It has no effect unless the workflow is served byrealm listen.
Project extensions
Section titled “Project extensions”extensions: ../../dist/registry.js # string | string[] — RELATIVE paths onlyDeclares 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_workflowtool) 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):
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.