The Specification on One Page
Specification
Section titled “Specification”Status: Draft · Spec version: 0.6 · Format version: workfile/1
Workfile is an engine-independent format for workflows. A Workfile is one document in that format, and it holds one workflow. A Workfile calls connectors and actions that a catalog publishes. A connector manifest declares each action’s argument schema and its operational behavior: error classification, retry policy, and idempotency. This document defines that manifest format as well.
Workfile follows a single-assignment model. Steps are referenced by id, and results are immutable. The grammar is small, and each construct has one representation over a restricted YAML subset. A Workfile is therefore statically checkable, and a tool can generate one mechanically.
Scope and conformance
Section titled “Scope and conformance”What this specification defines
Section titled “What this specification defines”- the Workfile document format and its serialization;
- the expression language and the core filter set;
- the connector manifest format, the filter package manifest format, and the schema overlay format;
- the type system, the error vocabulary, and the trigger kinds;
- the static validation rules that every implementation applies before execution;
- the execution semantics, the recording obligations, and the replay guarantee;
- the conformance levels and the capability names.
What this specification does not define
Section titled “What this specification does not define”This standard defines no vendor connector. The standard library defines the canonical connectors and filter packages of the reserved namespaces, and is part of this standard. Beyond the library, this standard defines no connector, no action, and no event.
This standard defines no execution strategy. An implementation can compile a Workfile to an intermediate form, interpret it directly, or lower it onto a host engine.
This standard defines no storage format for a run record, no wire protocol, and no service address.
Conformance targets
Section titled “Conformance targets”An implementation conforms at one of three levels — Validator, Executor, or Durable — and with a declared capability set. The conformance rules state the full conditions.
Requirements are attributed by subject:
- A requirement about parsing, resolution, or validation binds every level.
- A requirement about executing a step, calling an action, or recording an outcome binds Level 2 and Level 3.
- A requirement about suspension, resumption, or replay binds Level 3.
Requirement terms
Section titled “Requirement terms”The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL are to be interpreted as described in RFC 2119 and RFC 8174. These words have their normative meaning only when they appear in uppercase.
The word “can” states a capability, and “cannot” states its absence. Neither grants nor withholds permission.
Normative and informative content
Section titled “Normative and informative content”The pages of this specification, its annexes, the error code registry, the standard library, and the conformance suite are normative. The overview, the construct status page, and the changes page are informative, as is every document outside this specification. An informative document never states a requirement, and never changes one.
Examples, tables marked as illustrative, and paragraphs that begin with “Note:” are informative. An example never adds a requirement. Where an example and a requirement disagree, the requirement governs and the example is a defect.
Classes of behavior
Section titled “Classes of behavior”This document assigns every behavior to one of three classes.
- Specified. The behavior is stated here or in the conformance suite. Every conforming implementation produces it.
- Implementation-defined. The implementation selects the behavior and MUST document its selection. Diagnostic presentation, the storage of a run record, and the scheduling order of concurrent work are implementation-defined.
- Unspecified. More than one behavior is permitted, and an implementation need not document its selection. The order in which independent branches of a
parallelstep contact external systems is unspecified.
This standard defines no undefined behavior. Every construct produces one of the outcomes that this specification names, or an evaluation fault, or a validation error.
Overview
Section titled “Overview”The four artifacts
Section titled “The four artifacts”A deployment holds four kinds of document. Each has one author and one purpose.
| Artifact | Written by | Declares |
|---|---|---|
| Workfile | a workflow author | what should be done, as an ordered list of steps |
| Connector manifest | a connector author | one integration: its actions, its triggers, and their operational behavior |
| Schema overlay | a connection owner | the tenant-defined fields of one connection |
| Filter package manifest | a package author | a namespaced set of pure filters, with fixtures |
A Workfile names a connector and an action. It does not declare either. The manifest declares the argument schema, the error classification, the retry policy, and the idempotency promise. An operational detail therefore belongs to whoever knows the dependency, and not to whoever writes the workflow.
A catalog publishes and versions manifests. This standard defines the manifest format. Beyond the standard library and its canonical namespaces, it defines no connector and no action.
What a run does
Section titled “What a run does”A run passes through five stages.
- Resolution. The implementation pins connector versions, filter package versions, overlays, template content, and the callee of every
call. - Validation. The implementation checks the document against the resolved manifests, and rejects it before any action executes.
- Execution. Steps execute in list order. Each step reaches exactly one outcome.
- Recording. The implementation records every invocation, every decision, and every outcome.
- Replay. The record reproduces the run exactly, without contacting a dependency and without reading a clock.
Expressions are pure and total, so stage 5 follows from stage 4. A run can suspend for weeks and resume.
Terminology
Section titled “Terminology”action. One named operation of a connector, called as connector.action.
action call. A step body that names an action and supplies its arguments.
agent node. A step body that delegates a bounded sub-task to a model.
binding. A name that holds one value or one scope for the life of a run. A binding is created once and is never reassigned.
capability. A named execution feature that an implementation declares.
catalog. A service or collection that publishes and versions manifests and packages.
connection. The credentials and connection parameters that reach one account of one connector.
connector. One named integration, declared by a manifest.
construct. A step body other than an action call: value, render, route, choose, for_each, parallel, wait, wait_for, repeat, call, stop, run, or agent.
deployment environment. The configuration that supplies connections, workflow identity, capabilities, and policy to an implementation.
evaluation fault. A condition that stops an expression from producing a value at run time.
extension point. A manifest schema entry that a connection defines rather than the vendor.
extension region. A value whose schema is an extension point.
filter package. A published, namespaced set of extension filters with fixtures.
guard. An expression in a position that requires a boolean: when, a choose arm, until, the condition of a conditional expression, the argument of keep, and the outcome of a reconcile.
implementation. Software that claims conformance at a declared level.
manifest. The document that declares one connector, or one filter package.
outcome. The recorded result of a step: succeeded, skipped, failed, or not_run.
part. One named rendered string that a multi-part render step binds.
partial. A template fragment that produces no binding and exists to be included.
projection. A map of names to value-shaped structures, evaluated in a stated scope, that creates bindings in another scope. into, outputs, and with are projections.
promise. A manifest boolean with a defined operational consequence: idempotent, deterministic, validated_output, self_hosted.
run. One execution of one workflow.
run record. The ordered history of a run, sufficient to reproduce it.
scope. A set of bindings. Scopes nest.
schema overlay. A per-connection document that declares the fields of a connector’s extension points.
step. One element of a step list: a map with exactly one key, which is the step’s id.
step body. The value under a step’s id: an action call or a construct.
template. Prose or markup that the workflow owns, rendered by a render step.
trigger. A declared event source that starts a run.
Workfile. One document in the format that this standard defines.
Types and Values
Section titled “Types and Values”One type system covers every typed position in the format. A Workfile’s inputs, a state’s accepts, an agent step’s returns, and every field declaration in a manifest or an overlay use the type expressions below. The expression language operates on values of these types.
Type expressions
Section titled “Type expressions”The type expressions are string, int, float, bool, timestamp, duration, file, json, enum[...], list[T], map[string, T], and object types expressed as JSON Schema. The grammar states their form. The serialization rules require that a type expression is always quoted.
Object types use JSON Schema draft 2020-12. A $ref MUST be a local pointer into the same document, of the form #/schemas/<name>. Implementations MUST reject an external reference.
file is a reference to content in blob storage, with a content type and a size. Implementations MUST NOT embed file contents in run state.
Expressions operate on run state, so no expression can compute over a file’s contents. A checksum, a text extraction, or a format conversion is always a connector action, never a filter chain. The canonical data connector holds content operations that belong to no one system.
Representation and comparison
Section titled “Representation and comparison”Integers. An int is a signed 64-bit integer, and arithmetic on it is exact. A result outside the representable range is an evaluation fault.
Floats. A float is an IEEE 754 binary64 value. NaN, positive infinity, and negative infinity are not values of this format. An operation that would produce one of them is an evaluation fault.
Numeric operators. +, -, *, /, and % accept numbers only. An operation with one int operand and one float operand converts the int to float and yields a float. / always yields a float. % accepts two int operands only, and its result takes the sign of the dividend. Division or modulo by zero is an evaluation fault. An operand outside an operator’s domain is an evaluation fault. Strings compose by interpolation, format, or join, and never by +.
Strings. A string is a sequence of Unicode code points. Implementations MUST NOT normalize a string value on parse, on binding, or on comparison. Equality and ordering compare code point sequences. Note: two strings that Unicode considers canonically equivalent can compare unequal. The case filters use Unicode simple case mapping and are independent of locale.
Timestamps. A timestamp holds an instant with millisecond precision and a display zone. A literal is an RFC 3339 date-time with a mandatory time offset. Implementations MUST reject a second value of 60. Equality and ordering compare the instant alone, so two timestamps that name one instant in different zones are equal. to_timezone changes the display zone only. format_time is the only filter that reads the display zone. A zone is named by an IANA Time Zone Database identifier, or by a fixed offset. Implementations MUST record the time zone database version in the resolution of every run.
Durations. A duration is a signed count of milliseconds. The literal grammar takes one or more <integer><unit> components, with units ms, s, m, h, and d, in strictly descending order of unit, each unit at most once. 1h30m and 500ms are durations. A day is exactly 86400000 milliseconds, and has no calendar or daylight-saving meaning. A literal is never negative. diff can produce a negative duration.
Collections. A list is ordered. A map has string keys. Lists are equal when they have equal length and equal elements in order. Maps are equal when they have the same key set and equal values.
Files. A file is an opaque reference. Equality compares the reference. No ordering is defined.
Equality. == and != are total and never coerce. Values of different types are unequal, and null is equal only to null. int and float are both numbers, so 1 == 1.0 is true.
Ordering. <, <=, >, and >= are defined between two numbers, two strings, two timestamps, or two durations. Any other pairing, including an ordered comparison against null, is an evaluation fault. sort, sort_by, min, and max use this ordering, and a collection that mixes ordered types is an evaluation fault.
Booleans. There is no implicit truthiness. Every guard position requires a boolean: when, a choose arm, until, the condition of a conditional expression, the argument of keep, and the outcome of a reconcile. A guard of any other type is a validation error where the type is statically known, and an evaluation fault otherwise. not, and, and or accept booleans only.
Values typed json. A value whose type this standard cannot establish statically is typed json. A number in such a value that has no fraction and no exponent, and that fits the int range, is an int. Every other number is a float.
Construct Status
Section titled “Construct Status”A construct advances from draft to stable when two implementations that share no code pass the conformance suite for it.
Every construct below is draft. No implementation has claimed a conformance level, so no construct has met the rule. The conformance suite holds cases for these constructs, and the schema check runs over the structural subset, but a conformance runner is part of an implementation and none exists yet.
The tables also index which capability each construct requires.
The document
Section titled “The document”| Construct | Capability |
|---|---|
| Action call | — |
value |
— |
render |
— |
when |
— |
route |
— |
choose |
— |
for_each |
unbounded-iteration, for max: unbounded |
parallel |
parallel |
wait |
durable-timers |
wait_for |
durable-timers, signals |
repeat |
durable-timers |
call |
sub-workflows |
stop |
— |
run |
run-python, run-javascript |
agent |
agent-nodes |
The states profile |
states-profile |
Triggers and recovery
Section titled “Triggers and recovery”| Construct | Capability |
|---|---|
Trigger when and as |
— |
queue_by |
queue-by |
batch |
batching |
Multiple triggers and into |
— |
Step policy: retry, optional, must_succeed |
— |
| Scope recovery | durable-timers, for then: hold |
undo |
— |
on_unknown and reconcile |
— |
The catalog and the expression layer
Section titled “The catalog and the expression layer”| Construct | Capability |
|---|---|
| Connector manifest | — |
| Type system | — |
| Schema overlays | schema-overlays |
| Expression language | — |
| Core filter set | — |
| Filter packages | — |
| Standard library | lib-* |
Deferred
Section titled “Deferred”These are named in the specification and are not defined in this version.
| Item | Where | Note |
|---|---|---|
| Template packages | render |
The format is deferred to a future version. |
The wf namespace |
Canonical and reserved namespaces | Reserved for operations on workflows and runs. Not yet defined. |
| Ordering across batches | batch |
Not defined in this version. |
Code in a separate file, for run |
run |
This version defines no way to reference it. |
| Format-directed time parsing | Excluded by contract | Left out of core and out of the current library. |
Document Structure
Section titled “Document Structure”A Workfile is a single YAML document with the following top-level keys.
workfile: 1 # optional; major format version this file targetsname: inbox_triage # optional, ^[a-z][a-z0-9_]*$owner: support-team # optional, opaqueversion: 7 # optional, opaque
on: { ... } # optional trigger declarationinputs: { ... } # optional typed parametersoutputs: { ... } # optional result projectionrecovery: { ... } # optional file-level recovery policy
steps: [ ... ] # required, unless `states` is presentinitial: awaiting_approval # required with `states`states: { ... } # alternative to `steps`A file MUST contain exactly one of steps or states. A file that declares states MUST declare initial. A file that declares steps MUST NOT declare initial.
Implementations MUST reject an unknown top-level key. A key whose name begins with x- is not an unknown key.
Identity
Section titled “Identity”name is optional. When it is absent, the deployment environment supplies the workflow’s identity. Implementations MUST record a name for every run.
owner and version are opaque. Neither is readable from an expression, and neither affects evaluation. version is an integer that an author increments when the definition changes. Implementations MUST record both values with every run. A deployment environment MAY use either value to select a definition.
workfile declares the major format version that the file targets. An implementation that does not support that version MUST reject the file. When workfile is absent, an implementation assumes its own highest supported major version. A resolved definition MUST record the version, whether or not the source declared it.
Inputs
Section titled “Inputs”inputs declares the typed parameters that a caller supplies.
inputs: priority_domains: type: "list[string]" default: [] required: falseEach entry is a field declaration, over the type system. Implementations MUST validate every input value before the run starts. An input that declares required: true and has no supplied value is a validation error. An absent optional input binds its default, or null when it declares none.
Inputs are readable as inputs.<name> in every scope.
Outputs
Section titled “Outputs”outputs declares the workflow’s result. It is a projection, evaluated once after the last step completes.
outputs: ticket_url: "{{ ticket.url }}" category: "{{ classify.category }}"Names MUST match ^[a-z][a-z0-9_]*$ and MUST be unique. The expressions are evaluated in the root scope and are subject to purity.
Implementations MUST evaluate outputs on any successful terminal outcome, including the outcome of a stop step. A reference to a step that did not run resolves to null, as a reference to a skipped step does. Implementations MUST NOT evaluate outputs when a run fails.
The projection is the run’s result. It is recorded, and a call step binds it. A file that declares no outputs produces an empty result, and a path into a call step on such a file is a validation error.
Run metadata
Section titled “Run metadata”Every run exposes a run binding in the root scope:
| Path | Type | Meaning |
|---|---|---|
run.id |
string |
Unique per run. Identical on every replay. |
run.started_at |
timestamp |
The instant the run started. Recorded once, and identical on every replay. |
run.started_at is the only source of the current instant. A deadline or an offset derives from it with the time filters. There is no filter that reads a clock.
Names, Scope, and Single Assignment
Section titled “Names, Scope, and Single Assignment”A step is a map with exactly one key, and that key is the step’s id. There is no separate id field, and no step is anonymous.
- classify: # the id is `classify` llm.categorize: { ... }Ids MUST be unique within their enclosing scope and MUST match ^[a-z][a-z0-9_]*$.
Reserved names
Section titled “Reserved names”The names run, inputs, trigger, current, and error are reserved for the bindings that this specification defines. A file MUST NOT use a reserved name as a step id, a trigger as binding, an into binding, an outputs name, or a state’s accepts name.
Note: run is also the key of a step body. A step body key is not an expression path, so the two uses do not conflict. {{ run.id }} always names the run metadata.
Single assignment
Section titled “Single assignment”An id names the binding that holds the step’s result. Bindings are created once and are never reassigned. No syntax mutates a binding, and one id resolves to one value for the life of a run.
A step binds either a value or a scope, according to its body kind. The table of step bindings states which body kinds bind which.
- A binding that holds a value is read directly:
{{ classify.category }}reads a field of an action result. - A binding that holds a scope is read by the ids inside it:
{{ triage.lead.id }}reads theleadstep of the branch that arouteexecuted.
Scopes
Section titled “Scopes”Scopes nest. A step MAY reference a binding in its own scope and in any enclosing scope. The following constructs create a scope: route cases, choose arms, for_each iterations, parallel branches, repeat iterations, and states entries. A binding created inside such a construct is visible within it, and from outside through the construct’s own id.
steps: - classify: { ... } # binds `classify` in the root scope - triage: route: "{{ classify.category }}" cases: sales: - lead: { ... } # binds `lead` inside the `sales` case # visible outside as `triage.lead`An inner binding does not shadow an enclosing one. Ids are unique within a scope, and a construct’s bindings are reached through its own id.
A trigger binds its payload in the root scope. That binding follows the rules of this section. A root-scope step id that repeats the binding’s name is a collision, and as MUST resolve it.
Triggers
Section titled “Triggers”on: mail.received: inbox: support@acme.com connection: acme-google as: message when: "{{ message.recipient != 'noreply@acme.com' }}" # optional guard queue_by: "{{ message.thread_id }}" # optionalon names exactly one trigger, connector.event, from the catalog. Implementations MUST validate its configuration keys against the trigger’s declared input schema.
as names the binding for the trigger payload. It is optional, because every trigger declares a default binding name, binds, in its manifest. as overrides that default.
as is REQUIRED in two cases: when a file declares more than one trigger, and when the manifest’s default collides with a step id in the root scope. If as is absent in either case, implementations MUST reject the file. Implementations MUST NOT resolve the collision without an explicit as.
connection names a credential binding that the deployment environment resolves. It MAY be omitted when the environment defines one connection for that connector.
{{ trigger.name }} is the as name of the entry that fired, or the manifest default when the entry declares no as.
when guards the trigger. It MAY reference only the trigger binding and inputs. When it evaluates to false, no run starts. Implementations SHOULD record a suppressed event.
An evaluation fault in a trigger’s when has no run to fail. The event MUST NOT start a run, and the fault MUST be recorded as an error, distinct from ordinary suppression.
queue_by
Section titled “queue_by”queue_by declares an ordering key. Implementations evaluate it once per event, over the trigger binding and inputs, in the same scope as when.
Runs whose keys are equal form one queue and execute one at a time, in the order that the events arrived at the implementation. Runs whose keys differ are unconstrained. Without queue_by, the implementation starts a run for each event with no ordering.
A queue is identified by the pair of the workflow’s identity and the key value, so runs of different workflows never share a queue. Two entries of a multi-trigger declaration that produce equal keys share one queue.
Keys compare by the equality rules. A key that evaluates to null is an evaluation fault, handled as a fault in a trigger’s when.
A run holds its key until the run reaches a terminal outcome, including while it is suspended.
batch groups events before a run starts.
on: mail.received: { inbox: support@acme.com } batch: { size: 100, max_wait: 5s, partition_by: "{{ message.account_id }}" }size bounds the number of events in one batch. max_wait bounds how long the implementation waits to fill a batch. partition_by guarantees that a batch holds events from one partition.
Implementations MUST evaluate when and partition_by once per event, over that single event. The trigger binding holds one event during those evaluations. The run’s trigger binding holds the list of events in the batch.
A trigger entry MUST NOT declare both batch and queue_by. Ordering across batches is not defined in this version.
Multiple triggers
Section titled “Multiple triggers”on MAY be a list of triggers. Each entry then requires its own as.
on: - rss.item_published: feed: https://blog.acme.com/feed.xml as: item - sheet.row_added: sheet: Content Calendar as: rowExactly one entry fires for a given run, and its binding holds the payload. The binding of every entry that did not fire resolves to null, as a reference to a skipped step does.
Each entry MAY declare into: a projection of its payload into shared bindings, evaluated before the first step.
on: - rss.item_published: feed: https://blog.acme.com/feed.xml as: item into: post: { title: "{{ item.title }}", body: "{{ item.summary }}", link: "{{ item.url }}" } - sheet.row_added: sheet: Content Calendar as: row into: post: { title: "{{ row.title }}", body: "{{ row.body }}", link: "{{ row.link }}" }into expressions are evaluated over the payload of the entry that fired, and are subject to purity. When one entry declares into, every entry MUST declare into. All entries MUST bind the same names with the same nested key structure, so a path that resolves for one trigger resolves for every trigger. The bound names are ordinary root-scope bindings under the scope rules.
A single-trigger file MUST NOT declare into. With one payload shape, a value step is the one representation of that projection.
Steps and Actions
Section titled “Steps and Actions”steps is an ordered list. Each element is a step: a map with exactly one key, which is the step’s id. The value is the step body — an action call, or one of the constructs of value and render, control flow, and code and agents.
An action call names connector.action and supplies its arguments:
- ticket: helpdesk.new_ticket: queue: tier1 subject: "{{ message.subject }}"Implementations MUST validate arguments against the manifest’s input schema: required fields present, no unknown fields, and types and enums satisfied.
Steps execute in list order. The order of declaration is the order of execution. There is no separate dependency declaration, and implementations MUST NOT reorder steps.
What a step binds
Section titled “What a step binds”| Body kind | Binds |
|---|---|
| action call | the action’s declared output — a value |
value |
the evaluated structure — a value |
render |
the rendered string, or a map of part name to rendered string — a value |
run |
the value that the sandboxed code returned |
agent |
a value that satisfies the declared returns schema |
call |
the callee’s outputs projection — a value |
route, choose |
the scope of the branch that executed |
for_each, repeat |
a list of iteration scopes, indexed by position |
parallel |
a scope whose members are the branch scopes, named by branch |
wait_for |
a scope with the members status and event |
wait, stop |
nothing readable |
A path into the id of a wait step or a stop step is a validation error. Both still take an id, which names the step in the run record.
Modifiers
Section titled “Modifiers”A step body MAY include the following modifier keys beside its body. Each modifier is permitted only on the body kinds that the table names.
| Key | Meaning | Permitted on |
|---|---|---|
when |
Guard expression | every body kind |
timeout |
Wall-clock limit for the step | action call, run, agent, call, for_each, parallel, repeat |
retry |
Overrides the manifest’s default retry policy | action call, run |
idempotency_key |
Expression producing the key that the action’s idempotency parameter receives | action call |
must_succeed |
Failure is not subject to an enclosing policy | action call, run, agent, call, route, choose, for_each, parallel, repeat |
optional |
Failure is recorded, and the run continues | the same kinds as must_succeed |
undo |
Compensating action | action call |
on_unknown |
Behavior on an ambiguous outcome | action call |
reconcile |
Reconciliation that determines the true outcome | action call |
connection |
Overrides the environment’s default connection for this connector | action call |
recovery |
Scope recovery policy | route, choose, for_each, parallel, repeat |
Implementations MUST reject a modifier on a body kind that the table does not name. Implementations MUST reject a step that declares both optional and must_succeed.
A construct’s own keys are part of the body, and are not modifiers. with, match, code, network, cases, else, then, steps, as, max, concurrency, on_error, for, until, every, goal, tools, returns, budget, and on_exhausted are construct keys. The section that defines each construct states which of them it takes.
timeout accepts a literal duration or an expression that yields one. wait and wait_for do not accept the timeout modifier, because timeout is a key of the wait_for construct itself.
value and render
Section titled “value and render”value and projections
Section titled “value and projections”A step whose body is value binds the result of a pure expression. It is a named intermediate value.
- channel: value: "{{ {'sale': '#wins', 'ticket': '#support'}[event.type] | default('#general') }}"
- lead: value: email: "{{ payload.email | trim | lower }}" first_name: "{{ payload.name | split(' ') | first }}"When value is a map or a list, implementations evaluate the expressions at its leaves and bind the structure as data. A value step is subject to purity: no I/O, and no nondeterminism.
A projection is a map of names to value-shaped structures, evaluated in a stated scope, that creates bindings in another scope. Projections obey every rule of this section. The projections in this specification are outputs, a trigger’s into, a transition’s into, and the with of render, agent, and call. Each supplies named values to a construct that has a closed scope.
match is not a projection. It supplies correlation values that select which event may reach a suspended run, and it creates no binding.
render
Section titled “render”A step whose body is render evaluates a local template against supplied bindings, and binds the rendered text. A local template is prose or markup that the workflow owns. A render step is pure and subject to purity: no I/O at run time, and no nondeterminism.
render takes a string or a map. A string is a path to a template file, or to a directory of them. A map is an inline template, written in the Workfile itself. Both forms bind the same results and obey the same rules.
- reminder: render: templates/cart_reminder_1 with: items: "{{ cart.items }}" discount: SAVE10
- alert: render: text: | {{ ticket.id }} passed its deadline at {{ due | format_time('%H:%M') }}. with: ticket: "{{ ticket }}" due: "{{ ticket.due_at }}"Inline templates. An inline map has exactly one key. text, html, and json hold a template body as a string, and the key names the escape context, as a file extension does. parts holds a map of part name to single-key body map. The step then binds a map of part name to rendered string, as a directory does. Part names MUST match ^[a-z][a-z0-9_]*$ and MUST be unique.
- confirmation: render: parts: subject: { text: "Order {{ order.id }} is confirmed" } body_txt: { text: "Thanks, {{ order.customer_name }}." } body_html: { html: "<p>Thanks, {{ order.customer_name }}!</p>" } with: { order: "{{ order }}" }A template has no ambient scope. Its variables are the names in with plus the block bindings below. A binding that is in scope at the step is invisible inside the body until with supplies it.
Files. A template needs no registration and no schema file. A path is a string literal, resolved from the project root by the same rule as call, and MUST NOT contain an expression. Template selection is static in both forms. A workflow that chooses among templates does so with route or choose over static render steps.
Parts. When the path names a directory, each non-partial file in it is a part. The step binds a map of part name to rendered string. The part name is the filename without its extension. When two files share a stem, the extension is appended after an underscore: subject.txt, body.txt, and body.html bind subject, body_txt, and body_html. Part names MUST match ^[a-z][a-z0-9_]*$ after this mapping, and MUST be unique. When the path names a single file, the step binds the rendered string directly. A file whose name begins with _ is a partial: it is not a part, it produces no binding, and it exists to be included.
The template body. Interpolations are {{ }} expressions in the grammar of operators, over a scope that holds exactly the names that with supplies plus any block bindings. The core filters and the namespaced filter packages apply unchanged. Two block forms exist, and no others:
{{#each items as item}} <tr><td>{{ item.name }}</td><td>{{ item.qty }}</td></tr>{{/each}}
{{#if discount != null}} <p>Use code {{ discount }} at checkout.</p>{{else}} <p>Complete your order soon.</p>{{/if}}#each iterates a list with an explicit binding. There is no implicit context, and current resolves nowhere in a template. #if takes a boolean expression and an optional {{else}}. Blocks nest.
{{> _line_item.html}} includes a partial, which renders with the bindings that are in scope at the include site. A partial MUST NOT include another partial, and MUST NOT include itself. The inclusion depth is therefore at most one partial below a template.
Inferred inputs. A validator reads the variables that a template requires from its body. Implementations MUST check with against the template in both directions, before any run starts:
- a variable that the template or its included partials reference, and that
withdoes not supply, is an error; - a
withkey that the template never references is an error; - a variable is optional when every reference to it is guarded: wrapped in
default(...), or reached only inside an{{#if}}whose condition establishes that it is non-null. An omitted optional variable bindsnull. A bare reference anywhere makes the variable required.
Escaping. The escape context comes from the file extension of a file template, and from the body key of an inline template. Escaping applies to every interpolation, and there is no opt-out. .html and html escape HTML. .json and json render an interpolation as JSON string content. text and every other extension escape nothing. A fragment that composes without escaping is a partial, rendered in the same context. A partial MUST have the extension that matches the context of every template that includes it.
Resolution and replay. Template content is part of the workflow’s definition, not run data. Implementations MUST resolve file content when a run starts, and MUST record it by content hash. An edit therefore never alters a run that is already in progress, and a resolved definition MUST embed the resolved content. An inline body needs no resolution, because the document holds it. A fault in an interpolation is an evaluation fault.
Templates that several projects share belong in template packages, published and namespaced like filter packages. Their format is deferred to a future version.
Code and Agents
Section titled “Code and Agents”run and agent hand work to a sandbox or to a model. Each declares its boundary in the file.
run executes sandboxed code, for logic that the declarative layer cannot express.
- wow_deltas: run: python code: | cur, prev = ctx.metrics.this_week, ctx.metrics.last_week return {k: (cur[k] - prev[k]) / prev[k] for k in cur if k in prev} network: [] # default: no egress timeout: 10sThe value of run is a language identifier from the registry of run languages. This version registers python and javascript. Each registered language has its own capability name, so an implementation declares the languages that it executes.
code holds the source inline. This version defines no way to reference code in a separate file.
ctx is bound to the readable scope, and exposes the same tree that the expression language walks, so {{ classify.category }} and ctx.classify.category denote the same value. The step binds the JSON-serializable value that the code returns.
Implementations MUST NOT give sandboxed code the ability to invoke a connector action, to schedule work, or to read the filesystem. Implementations MUST grant no network egress by default. network grants egress to the listed hosts, and to no others.
timeout bounds one execution. Implementations MUST also apply a memory bound and a result size bound, and both are implementation-defined.
run-python and run-javascript are capabilities.
agent is a bounded autonomy node, for a sub-task whose control flow cannot be specified in advance. An action such as llm.categorize is called with declared inputs and outputs; an agent node differs only in that the control flow inside it is not declared.
- resolve: agent: triage-assistant goal: "Find the account for {{ sender }}; it may be under a parent org." with: { sender: "{{ message.from }}" } tools: [crm.search, billing.lookup] returns: { account_id: "string" } budget: { steps: 8, tokens: 20000 } on_exhausted: [ ... ]Scope. with is a projection, and it is the node’s entire readable context together with the rendered goal. Implementations MUST NOT expose any other binding to the node. A goal is an ordinary expression-bearing scalar, evaluated in the calling scope.
Tools. tools is an allowlist of connector actions from the resolved catalog. Implementations MUST NOT permit the node to invoke an action outside the list. Implementations MUST validate every invocation against the action’s manifest, exactly as they validate an ordinary action call, and MUST record every invocation and its result.
Result. returns declares the result schema, as field declarations. Implementations MUST validate the node’s result against it, and MUST fail the step when the result does not satisfy it.
Budget. budget is REQUIRED, and MUST declare at least one bound. The defined bounds are steps (the number of action invocations), tokens, and duration. Cost in a currency is not a bound of this format, because a price is a fact about one provider.
When a bound is reached, implementations MUST stop the node. on_exhausted is a step list that then runs. When a file declares no on_exhausted, exhausting a bound fails the step.
Replay. Implementations MUST record the actions that the node took and their results. The run then replays exactly, although the original execution was not deterministic.
agent-nodes is a capability.
The states Profile
Section titled “The states Profile”For a long-running process whose shape is event-driven rather than sequential, a Workfile MAY declare states instead of steps.
initial: awaiting_approval
states: awaiting_approval: on_enter: [{ notify: { slack.post: { channel: "#sales-ops" } } }] timeout: { after: 24h, goto: escalated } on: decision: event: slack.approval match: { message: "{{ notify.ts }}" } route: "{{ decision.action }}" cases: approve: { goto: provisioning, into: { approver: "{{ decision.user }}" } } reject: { goto: closed } amend: { goto: awaiting_approval, reset_timeout: true } else: { goto: escalated }
provisioning: accepts: approver: { type: "string", required: true } steps: [{ account: { crm.provision: { owner: "{{ approver }}" } } }] goto: closed
escalated: on_enter: [{ page: { slack.post: { channel: "#sales-leads" } } }] goto: closed
closed: { final: true }Each state declares the events that are valid in it, and the transition that each causes. on_enter and steps are ordinary step lists, and follow every rule above. A state MAY declare recovery, which applies to its step lists under the scope recovery rules.
Implementations MUST verify that initial names a declared state. Implementations MUST verify that every goto names a declared state. Implementations MUST verify that at least one final state is reachable from the initial state.
Transitions. A transition names the next state. It takes one of two forms.
gotonames one state. It acceptsintoandreset_timeout: true.routetakescasesand a requiredelse. Each case maps one value of the routing expression to a transition. The rules ofrouteapply: cases are unordered, dispatch is by equality, and a subject with a declared enum domain MUST be covered exhaustively.
A transition appears in three places: an on entry, a timeout, and a state’s own exit after its steps complete. timeout.after accepts a duration or a timestamp, literal or expression. Dispatch that needs a comparison belongs in the steps of a state, ahead of a route over the result.
Subscriptions. An on entry declares one subscription. Its key names the binding for the event payload, which is in scope for that entry’s route and into expressions. event names one event, connector.event, from the catalog. match supplies the expected value of every correlation key that the event’s manifest declares. Implementations MUST NOT deliver an event to a run whose values do not match. Two entries MAY name one event with different correlation values.
A subscription binds the payload directly, and not the two-member scope of a wait_for. A subscription has no timeout of its own. A state’s own timeout covers that case, and it is a transition rather than a binding.
A state’s subscriptions are active only while the run is in that state. The run subscribes on entry, and unsubscribes on exit. Implementations MUST evaluate correlation values when on_enter completes, because those values can reference its results. Implementations MUST hold a correlated event that arrives after the run entered the state, and MUST deliver it once evaluation completes. Implementations MUST NOT interrupt a running step list: an event that arrives during on_enter or steps is delivered when the list completes. An event that the current state does not name MUST NOT change the run’s state, and implementations SHOULD record its arrival.
Data across a transition. A transition carries data with into: a projection of expressions in the source scope into bindings of the target. The target state declares what it takes in accepts, as field declarations. Every transition into a state MUST supply every name that its accepts declares required, and MUST NOT supply a name that accepts does not declare. Declared names bind in the target’s scope, and are read directly, as {{ approver }}. The initial state MUST NOT declare accepts, because no transition enters it.
State entries and scope. A run can enter one state more than once. Each entry creates a scope, exactly as an iteration of for_each does. Within a state, a step reads the bindings of the current entry by name, and reads the root scope by the scope rules. Bindings of one entry are invisible to every other entry and to every other state. A state name is not a readable path, because single assignment requires a binding to hold one value. Data that must outlive an entry crosses on a transition, in into. The record identifies each entry by position, so a run’s history holds every entry separately.
Outputs. Implementations MUST evaluate a states file’s outputs in the scope of the final state’s entry, which nests inside the root scope. Data that the result needs therefore reaches the final state through into and accepts.
A steps file is a states file with a single state.
states-profile is a capability.
Branching
Section titled “Branching”Control-flow constructs nest without a declared limit. Two constructs bound their own depth: call and partial inclusion.
Any step MAY include when. A step whose guard is false is skipped. A skip is a recorded outcome, distinct from success and from failure. A reference to a skipped step resolves to null.
- notify_exec: when: "{{ classify.category == 'sales' and enrich.deal_size > 50000 }}" slack.post: { channel: "#big-deals" }route is N-way dispatch on a single expression. else is required.
- triage: route: "{{ classify.category }}" cases: sales: [ ... ] support: [ ... ] else: [ ... ]route binds its id to the scope of the branch that executed. The branch’s step ids are read through that id: {{ triage.lead.id }}.
Cases are unordered. Exactly one case matches, and reordering cases cannot change behavior.
A case label is a value, and dispatch is by equality. choose covers dispatch that equality on one value cannot express.
When the routing expression resolves to a value with a declared enum domain, implementations MUST verify that a case or else covers every member of that domain. Implementations MUST reject a case whose label is outside the domain. A value has a declared enum domain when its type is an enum and its value is validated. Three sources qualify:
- an input, validated before the run starts;
- a name that a state’s
acceptsdeclares; - an output of an action that declares
validated_output.
An enum-typed value from any other source has no declared domain, and a route over it is checked only for duplicate labels.
choose
Section titled “choose”choose is an ordered choice: a chain of guards.
- triage: choose: - when: "{{ classify.category == 'sales' and enrich.deal_size > 50000 }}" then: [{ exec: { slack.post: { channel: "#big-deals" } } }] - when: "{{ classify.category == 'sales' }}" then: [{ lead: { crm.create_lead: { email: "{{ message.from }}" } } }] else: [ ... ]Implementations MUST evaluate arms in order, and MUST execute the first arm whose guard is true. else is required, and runs when no guard matches. choose binds its id to the scope of the arm that executed.
A choose with a single arm and an empty else is the representation of a guarded group: several steps under one condition. Implementations MUST NOT warn on the single-arm form.
route |
choose |
|
|---|---|---|
| Subject | one expression, equality | arbitrary boolean guards |
| Order | irrelevant | significant, first match |
| Exhaustiveness | verified against the enum domain | not verifiable; else covers it |
| Overlap | impossible | permitted, resolved by order |
Implementations SHOULD warn when a choose of two or more arms holds only equality guards against a single enum-typed path, because route expresses that dispatch with a stronger guarantee.
Branches of a route or a choose MAY bind different names. A path through the construct’s id is statically valid when it resolves in at least one branch. At run time, a path into a branch that did not execute resolves to null, as a reference to a skipped step does.
Iteration and Parallelism
Section titled “Iteration and Parallelism”for_each iterates a list. max is required, and declares the bound.
- attachments: for_each: "{{ message.attachments }}" as: file max: 25 concurrency: 4 on_error: continue # continue | fail_fast steps: - scan: { av.scan: { url: "{{ file.url }}" } } - store: when: "{{ scan.clean }}" s3.put: { key: "{{ message.id }}/{{ file.name }}" }The construct binds its id to a list of per-iteration scopes, indexed by position: {{ attachments[0].scan.clean }} and {{ attachments | length }}.
Each iteration creates a scope. No binding crosses from one iteration to the next. The loop binding that as names is visible inside the construct only.
Iteration order follows the source list. concurrency affects scheduling only, and MUST NOT affect results.
A source list longer than max fails the step before any iteration runs. Implementations MUST NOT truncate a source list.
max accepts a positive integer or the word unbounded. unbounded declares that the source list’s own length is the bound. An omitted max is a validation error: the author states the bound in every case. The source list is finite when the step starts, so the loop terminates under either value. unbounded-iteration is a capability, so a deployment can decline to execute an unbounded loop.
Failure within an iteration. on_error declares what a failed step inside one iteration does to the construct. It takes one of two values.
| Value | Behavior |
|---|---|
fail_fast |
The failure fails the construct. Iterations that have not started do not start, and iterations in progress are cancelled. Default. |
continue |
The failing iteration stops at its failed step. Other iterations run to completion, and the construct succeeds. |
A step of an iteration that did not run is recorded with the outcome not_run, and a reference to it resolves to null.
Every iteration scope holds a reserved error binding.
| Path | Type | Meaning |
|---|---|---|
error |
object? |
null when the iteration completed. Otherwise an object. |
error.step |
string |
The id of the step that failed. |
error.code |
string |
The error code, from the manifest’s error table or from the fault vocabulary. |
error.message |
string |
A human-readable description. Its text is implementation-defined. |
Failed iterations are therefore selected with the ordinary filters: {{ attachments | keep(current.error != null) }}.
parallel
Section titled “parallel”parallel runs named branches and joins them implicitly.
- enrich: parallel: crm: [{ lookup: { crm.find_contact: { email: "{{ message.from }}" } } }] billing: [{ lookup: { billing.account: { email: "{{ message.from }}" } } }] timeout: 30s on_error: continue # continue | fail_fastparallel binds its id to a scope whose members are the branch scopes, named by branch: {{ enrich.crm.lookup.deal_size }}. Branch names MUST match ^[a-z][a-z0-9_]*$ and MUST be unique. A branch MUST NOT reference another branch’s bindings.
Branch scheduling affects the order of external effects only, and MUST NOT affect results. The order in which branches contact external systems is unspecified.
on_error takes the values and the meanings that for_each defines. Each branch scope holds the same reserved error binding.
Waiting and Polling
Section titled “Waiting and Polling”Three constructs suspend a run: wait for an elapsed duration, wait_for for an external event, and repeat for a bounded poll.
wait suspends the run for a duration, or until an instant. Exactly one of for or until is present, and either can be an expression. Implementations MUST NOT hold a process open while a run is suspended, here or in wait_for.
- cooling_off: wait: { for: 2d }
- day_before: wait: { until: "{{ hire.start_date | minus(1d) }}" }wait_for
Section titled “wait_for”wait_for suspends the run until an external event arrives, or until the timeout elapses.
- ask: slack.post: { channel: "#sales-ops", text: "Approve {{ deal.name }}?" }
- approval: wait_for: slack.approval match: { message: "{{ ask.ts }}" } timeout: 24htimeout is REQUIRED, and is a key of the construct rather than a step modifier. A wait_for binds a scope with two members.
| Path | Type | Meaning |
|---|---|---|
status |
enum[received, timeout] |
Whether an event arrived before the timeout elapsed. |
event |
object? |
The event payload, or null when status is timeout. |
A timeout is not a failure. The run continues at the next step, and the author branches on status.
An arriving event reaches a suspended run by correlation:
- the event’s manifest declares the correlation keys in
correlate_on; - the waiting step supplies the expected value of each key in
match; - implementations MUST deliver an event only to a run whose supplied values match.
A wait_for that omits a value for a declared correlation key is a validation error. match selects which event may reach the run, and creates no binding.
repeat
Section titled “repeat”repeat is bounded polling. max is required.
- indexed: repeat: { every: 30s, max: 20, until: "{{ check.status == 'indexed' }}" } steps: - check: { search.status: { id: "{{ ticket.id }}" } }repeat binds its id to a list of iteration scopes, indexed by position, as for_each does. Each iteration creates a scope, and no binding crosses from one iteration to the next.
The first iteration runs immediately. Implementations MUST wait every before each later iteration. Implementations MUST evaluate until after each iteration completes, in that iteration’s scope, so {{ check.status }} names the just-completed iteration’s check step.
repeat completes successfully when until is true. When max iterations complete and until is still false, the step fails. That failure is an ordinary step failure, so optional and scope recovery apply to it.
max MUST be a positive integer, and repeat MUST NOT accept unbounded. max is what proves that a polling loop terminates.
Composition and Termination
Section titled “Composition and Termination”call invokes another Workfile as a step.
- refund: call: workflows/refunds/process.yaml with: { account: "{{ enrich.billing.lookup.id }}" } timeout: 7dThe path is a string literal, resolved from the project root, and MUST NOT contain an expression.
with supplies the callee’s inputs. It is a projection, evaluated in the calling scope. Implementations MUST supply a value for every input that the callee declares required, and MUST reject a name that the callee does not declare.
The step binds the callee’s outputs projection. A path into the step resolves against the callee’s declared output names. A call on a callee that declares no outputs binds an empty result, and any path into the step is a validation error.
Implementations MUST resolve a callee in order to validate a call. The resolution is recorded with every run.
Depth. A Workfile that contains a call step MUST NOT be the target of a call. The call graph is therefore at most two documents deep, and no cycle is possible. Deeper composition requires extraction into a separately invoked Workfile.
sub-workflows is a capability.
stop ends the run, wherever it appears. A stop inside a for_each iteration or a parallel branch ends the run, and does not end only that iteration or branch. Implementations MUST cancel sibling work that is in progress.
stop ends the run early and successfully, with the recorded outcome stopped and the given reason. Steps after it do not execute, and are recorded with the outcome not_run. stop is not a failure, and recovery policy does not apply to it. It is usually guarded:
- no_change: when: "{{ scrape.value == previous.value }}" stop: "value unchanged since last check"Implementations MUST evaluate outputs after a stop. A stop in a callee ends the child run successfully, and the calling step binds the callee’s outputs.
Failure and Recovery
Section titled “Failure and Recovery”Recovery is layered, and most files declare none. The connector manifest supplies the retry policy, the backoff, and the error classification.
Outcomes
Section titled “Outcomes”Every step reaches exactly one outcome, and implementations MUST record it.
| Outcome | Meaning |
|---|---|
succeeded |
The body produced a result. |
skipped |
The step’s when guard was false. |
failed |
The body did not produce a result, and retry did not recover it. |
not_run |
The step was never reached: it follows a stop, it is in a branch that did not execute, or it is in an iteration that stopped at an earlier step. |
A reference to a step whose outcome is skipped, failed, or not_run resolves to null.
Every run reaches exactly one terminal outcome: completed, stopped, or failed. A run can also be suspended or held, which are not terminal. While a run is held for an ambiguous outcome, the step that caused the hold has not reached an outcome. The run record holds the attempt and its classified code.
The default. A step fails only after its retry policy is exhausted. A failed step then fails the run, unless a policy applies. Implementations MUST apply the policies in this order.
must_succeed: trueon the step. The run fails, and no enclosing policy applies.optional: trueon the step. The failure is recorded, the run continues at the next step, and the failure does not count toward astop_afterthreshold.- The
on_errorof an enclosingfor_eachorparallel, where the step is inside one. - The
recoveryof the nearest enclosing scope that declares one. - Otherwise the run fails, with the outcome
failed.
When a policy fails the enclosing construct, that construct is itself a failed step. Implementations MUST then apply this ladder again, at the construct’s own level.
Implementations MUST reject a step that declares both optional and must_succeed.
Step-level policy
Section titled “Step-level policy” - lead: crm.create_lead: { email: "{{ message.from }}" } idempotency_key: "{{ message.id }}" must_succeed: true undo: { crm.delete_lead: { id: "{{ lead.id }}" } }
- notify: slack.post: { channel: "#sales" } optional: trueretry overrides the manifest’s default retry policy for one step. It takes the shape that the connector manifest defines. Implementations MUST NOT retry a failure that the manifest classifies fatal, and MUST NOT retry an evaluation fault.
idempotency_key is an expression that produces the value which the action’s declared idempotency parameter receives. run.id and a trigger payload identifier are correct components of such a key.
Scope-level recovery
Section titled “Scope-level recovery” - triage: route: "{{ classify.category }}" recovery: redo: this_and_after stop_after: { failures: 3, within: 5m } then: hold notify: support-team cases: { ... }recovery appears on a scope-creating construct or at the top level of a file.
redo declares what is re-run when a step inside the scope fails:
| Value | Behavior |
|---|---|
this_step |
The failed step only. Default. |
this_and_after |
The failed step and its transitive downstream closure within the scope. |
whole_group |
Every step in the scope. |
A redo re-executes each member of the set. A re-executed step binds its newest result, and the run record keeps every attempt. Compensation before a redo is specified in undo.
stop_after bounds total failures within a rolling window across the scope. When the bound is exceeded, then applies.
| Value | Behavior |
|---|---|
hold |
Suspend the run durably, for later replay. |
fail |
Fail the run. |
escalate |
Apply the policy of the enclosing scope. |
then: hold requires the durable-timers capability.
stop_after reads a clock, so its evaluation is not pure. Implementations MUST record each stop_after evaluation and the decision that followed, and MUST replay the recorded decision rather than re-evaluate it.
notify is an opaque identifier that the deployment environment resolves to a notification target. This standard defines no vocabulary for it, and it does not affect evaluation.
undo declares a step’s compensating action.
Under redo: this_and_after or redo: whole_group, every step in the redo set MUST be naturally idempotent, or idempotent with a declared idempotency_key, or MUST include undo. Implementations MUST reject a file that violates this rule, and the diagnostic MUST name the offending step.
A redo is the one trigger of an undo. Before a redo, implementations MUST run the undo of every step in the redo set whose outcome is succeeded and that declares one. Compensations run in reverse completion order. An idempotent member needs no compensation: its redo re-issues the action, with the same key where the action takes one.
An undo is an action call. Its arguments are evaluated in the scope as it stands before the redo, so they can read the compensated step’s result. Implementations MUST record the invocation and its result against the step it compensates. A failed undo fails the run: the state of the effect is then unknown, and the record names the invocation that failed.
A run that fails runs no compensations. Its record holds every effect, and cleanup is a deployment decision.
on_unknown
Section titled “on_unknown”An action’s outcome can be ambiguous: a timeout with no response, or a lost connection during a request. Such an outcome is neither success nor failure. on_unknown declares what happens.
| Value | Behavior |
|---|---|
retry |
Re-issue the action. Permitted only when the manifest declares the action idempotent and, where the action declares an idempotency_param, the step supplies idempotency_key. |
reconcile |
Run the declared reconcile steps to determine the true outcome. |
halt |
Suspend the run durably for human resolution. The run is then held. |
The default is determined statically:
retry, when the manifest declares the action idempotent and, where it declares anidempotency_param, the step supplies anidempotency_key;halt, in every other case.
- refund: billing.issue_refund: { ... } idempotency_key: "{{ message.id }}" on_unknown: reconcile reconcile: steps: - check: billing.find_refund: { external_id: "{{ message.id }}" } optional: true outcome: "{{ (check.status | default('absent')) == 'settled' }}" result: { refund_id: "{{ check.refund_id }}" }reconcile is a map with three keys.
stepsis a step list. It runs in a scope that nests inside the step’s scope, so enclosing bindings are readable. Its ids are readable byoutcomeandresult, and nowhere else.outcomeis a guard, evaluated in that scope after the steps complete. True means the action took effect.resultis avalue-shaped structure, evaluated in the same scope whenoutcomeis true. The step binds it.resultis REQUIRED when the action declares anoutput, and implementations MUST validate the value against that schema before binding.
When outcome is true, the step succeeds and binds result. When outcome is false, the action did not take effect. Implementations MUST record the attempt as a failure with the classified code of the ambiguous outcome. Remaining retry attempts then apply, and re-issue is safe for any action, because reconciliation established that the action produced no effect.
A failed step inside steps, or a fault in outcome or in result, leaves the outcome ambiguous. Implementations MUST then apply halt. optional applies inside steps, so a probe that can fail composes with default in outcome, as the example shows.
Implementations MUST reject on_unknown: retry on an action that the manifest does not declare idempotent, or on a step that supplies no idempotency_key while the action declares an idempotency_param. Implementations MUST reject a reconcile key on a step whose on_unknown is not reconcile. Implementations MUST reject an on_unknown: reconcile step whose reconcile is absent, omits steps or outcome, or omits result while the action declares an output.
Expressions
Section titled “Expressions”Expressions appear inside {{ }}.
{{ message.subject }}{{ classify.category == "sales" }}{{ message.attachments | length > 0 }}{{ enrich.crm.lookup.deal_size | default(0) }}{{ (classify.category == "sales" and enrich.deal_size > 50000) or (classify.category == "sales" and enrich.sender.key_client) }}The grammar annex is normative.
Interpolation and typing
Section titled “Interpolation and typing”An expression is the text between {{ and }} in a YAML scalar, after YAML unescaping, with leading and trailing whitespace removed. YAML quoting applies before this language sees the text.
A scalar yields a value by the following rules.
- A scalar that holds exactly one interpolation, and no other character, yields that expression’s value with its type.
max: "{{ inputs.batch_size }}"yields an integer. - A scalar that holds text outside an interpolation, or more than one interpolation, yields a string. Each interpolated value converts to a string by the rules of the
stringfilter. Anullconverts to the empty string. - A scalar that holds no interpolation is a literal, typed by the serialization rules.
The same rules apply inside a template body, where a template is always a string and every interpolation therefore converts to a string.
A literal {{ is written as a one-interpolation scalar over a string literal: "{{ '{{' }}".
Operators
Section titled “Operators”Terms are literals (string, int, float, bool, null), paths, list literals, and map literals.
Operators, tightest binding first:
| Operators | |
|---|---|
| 1 | ( ) grouping · . member · [ ] index · | filter |
| 2 | unary not · unary - |
| 3 | * / % |
| 4 | + - |
| 5 | < <= > >= |
| 6 | == != in |
| 7 | and |
| 8 | or |
| 9 | a if c else b conditional |
Parentheses group arbitrarily and are the only way to override precedence. and and or short-circuit. The conditional expression a if c else b yields a when c is true, and b otherwise. Implementations MUST evaluate only the selected arm.
The word operators not, and, or, in, if, and else MUST be separated from adjacent terms by whitespace. Whitespace around a symbol operator is optional.
Filters bind tighter than comparison, so {{ a | length > 0 }} parses as {{ (a | length) > 0 }}.
An expression yields exactly one value. There is no statement sequencing, no assignment, and no lambda.
Member access .name requires a name that matches ^[a-z][a-z0-9_]*$. A key outside that shape is reached by indexing with a string literal.
Indexing a map with an absent key, or a list with an index outside its range, yields null, so a table lookup composes with default. A negative list index counts from the end. This applies to dynamic indexing only: an unresolvable binding path remains a validation error.
Resolution and purity
Section titled “Resolution and purity”Every path MUST resolve to a binding that is in scope at that point. An unresolvable path is a validation error, not a runtime null.
An expression MUST NOT invoke a connector action, perform I/O, read a clock, read ambient state, or produce a nondeterministic value. Two evaluations against the same bindings MUST produce the same result on every conforming implementation.
Replay, durable suspension, and validation before execution depend on these constraints. Logic that cannot be written under them belongs in run.
Filters
Section titled “Filters”A filter applies as value | name or value | name(args). Filter arguments are themselves expressions, evaluated before the filter applies, so default(inputs.fallback) is as valid as default(0). Two filters are excepted: the arguments of each and keep are evaluated per element.
The core filter set is normative and closed within a spec version. Implementations MUST provide every filter in it. Implementations MUST NOT add a filter with a bare name.
That annex defines filter behavior, and not any implementation’s host language. It states every case where implementations are known to diverge: rounding at a midpoint, the directives of format_time, word boundaries in title, and the unit scale of human_size.
Selection and projection. The language has no lambda. A field predicate takes a path, an operator name from a closed set, and a value. A computed predicate and a projection each take one bounded expression, evaluated per element with current bound. Neither accepts an arbitrary body: no statements, no new names beyond current, and no nesting.
The op argument of where MUST be a string literal from the set ==, !=, <, <=, >, >=, in, not_in. It is never a computed value, so a validator can check the comparison. These are comparison names, not expression operators: not_in denotes the negation of in. An element whose path is null satisfies != and not_in only.
keep is the computed counterpart of where. Its argument is evaluated once per element, with current bound to the element. The argument MUST be boolean-typed.
{{ tickets | keep(current.priority == 'high' or (current.age_days | default(0)) > 7) }}each is the projection filter. Within its argument, current names the element under evaluation, and enclosing bindings remain visible.
{{ event.attendees | each(current.email | split('@') | last) | unique }}current resolves inside the argument of each or keep, and nowhere else. The argument of either filter is one expression, subject to purity. It MUST NOT contain an each or a keep, because per-element evaluation does not nest. Evaluation order follows the source list.
Implementations SHOULD warn when the argument of a keep is a single field comparison that where can express, because where is checkable without evaluation.
Extension filters
Section titled “Extension filters”The core set is closed, and the filter namespace is not. Extension filters come from filter packages, and are always namespaced with a dot:
{{ message.subject | text.slugify }}{{ order.total | money.to_currency("USD") }}A bare name is always core, and a dotted name is always an extension. A core filter that a later spec version adds therefore cannot collide with an extension name.
A filter package declares its filters in a manifest:
manifest: 1kind: filtersnamespace: textversion: 1.2.0
filters: slugify: input: "string" args: [] output: "string" fixtures: fixtures/slugify.jsonl wrap: input: "string" args: [{ name: width, type: "int", default: 80 }] output: "string" fixtures: fixtures/wrap.jsonlAn extension filter MUST satisfy the same contract as a core filter: pure, deterministic, and total, with no I/O. Its signature MUST be declared, and implementations MUST validate a call against it exactly as they validate connector arguments.
A package MUST ship input and output fixtures for every filter. Implementations MUST reject a package that does not. Implementations MUST resolve and version-pin filter packages when a run starts, as they do for connectors. An implementation that lacks a referenced package MUST reject the Workfile, and MUST NOT substitute another version.
This standard defines the manifest format and the contract. The standard library defines the canonical packages, and every other package is published independently.
Evaluation Faults
Section titled “Evaluation Faults”Static validation rejects everything that is knowable before a run starts. It cannot know every value. An evaluation that cannot produce a value at run time is an evaluation fault:
- division or modulo by zero;
- an integer result outside the
intrange, or an operation that would produce NaN or an infinity; - an arithmetic operator applied to a value that is not a number,
%applied to a value that is not anint, ornot,and, ororapplied to a value that is not a boolean; - an ordered comparison whose operands are not two values of one ordered type, including a comparison against
null; - an ordering filter applied to a collection that mixes ordered types;
inwhose right operand is not a list, a map, or a string;- a conversion filter applied to a value that it cannot convert unambiguously, such as
inton"n/a"; - a filter applied outside its declared signature, where the input’s type could not be established statically, as with a value typed
json; - a filter argument that is invalid at run time and was not a literal. A computed
formatstring with the wrong arity, and a computedmatchespattern that is not valid RE2, are both of this kind; - a guard that yields a value that is not a boolean, where the type could not be established statically.
Faults are deterministic. By purity, evaluation depends only on the bindings in scope, so a faulting expression faults identically on every re-evaluation and on every conforming implementation. Its handling follows from that:
- A fault in a step’s arguments, or in a
valuestep, fails that step. Implementations MUST classify the failure as fatal, and MUST NOT retry it, because re-evaluation cannot succeed. It is otherwise an ordinary step failure:optionalrecords it and continues, scope recovery observes it, andmust_succeedblocks on it. - A fault in a guard position fails the guarded step or construct. Implementations MUST NOT read it as false. Implementations MUST NOT record it as a skip, because a skip records a decision and a fault is the absence of one.
- A fault in a trigger’s
whenorqueue_byprevents the run from starting. - Implementations MUST record a fault as they record any other outcome, and MUST replay it as a fault.
Every fault condition has a code in the error code registry, as every condition of static validation does.
Connector Manifest
Section titled “Connector Manifest”A connector is one named integration. A Workfile calls it as connector.action, and reaches it through a connection. A connector’s manifest is the document that declares it: its authentication, the parameters of a connection, its actions, and its triggers. A catalog publishes and versions manifests. Filter packages have manifests of their own. A schema overlay is one connection’s refinement of a manifest, and is not itself a manifest. Elsewhere in this specification, “the manifest” means the connector manifest of the action or trigger under discussion.
A conforming implementation MUST validate Workfiles against manifests in this format. The manifest key reference indexes the same keys in a table. Where it and this page disagree, this page governs.
Every name that a manifest or an overlay declares — a connector, an action, a trigger, a connection parameter, an input field, an output field, or a schema — MUST match ^[a-z][a-z0-9_]*$. A vendor field whose name is outside that shape is mapped by the executor, or is reached by indexing with a string literal.
Connector manifest
Section titled “Connector manifest”manifest: 1connector: helpdeskversion: 2.1.0
auth: type: oauth2 scopes: [profile.read] # baseline: required by every action and trigger
connection: subdomain: type: "string" required: true in: host # contributes to the address pattern: "^[a-z0-9-]+$" source: user # user | auth prompt: "Your helpdesk subdomain"
actions: new_ticket: scopes: [tickets.write] # required by this action alone input: queue: { type: "enum[tier1, tier2, billing]", required: true } subject: { type: "string", max_length: 255, required: true } body: { type: "string" } files: { type: "list[file]" } output: id: { type: "string" } url: { type: "string" } idempotent: true idempotency_param: external_id deterministic: false validated_output: true rate_limit: { rps: 10, burst: 20 } retry: { attempts: 3, backoff: exponential, initial: 1s, max: 60s } errors: "429": { class: retryable } "409": { class: fatal, message: "A ticket already exists for this external_id" } "5xx": { class: retryable } sample: fixtures/new_ticket.json
triggers: ticket_updated: kind: webhook # webhook | poll | schedule | manual scopes: [tickets.read] binds: ticket # default binding name; `as` overrides correlate_on: [external_id] # fields a suspended run must match output: { $ref: "#/schemas/ticket" } sample: fixtures/ticket_updated.jsonField declarations. A field declaration describes one typed value. The same form declares an action’s input and output fields, a trigger’s output fields, a connection parameter, a Workfile’s inputs, a state’s accepts, an agent step’s returns, and an overlay’s fields. Its keys are a closed set.
| Key | Meaning |
|---|---|
type |
REQUIRED unless $ref is present. A type expression. |
$ref |
REQUIRED unless type is present. A local pointer to a named schema. |
required |
Boolean. The default is false. |
default |
A literal of the declared type, supplied when no value is given. |
pattern |
An RE2 pattern that a string value MUST satisfy. |
max_length |
A positive integer bounding the length of a string or a list. |
Implementations MUST reject an unknown key in a field declaration. Implementations MUST reject a field that declares both type and $ref, a field that declares neither, and a field that declares both required: true and default.
A connection parameter takes three further keys, defined below: source, prompt, and in.
Scopes. auth.scopes is the baseline: the scopes that every action and trigger of the connector requires. An action or a trigger declares in its own scopes the further scopes that it alone requires. An entry that declares no scopes requires the baseline alone. The set that a Workfile requires is therefore the baseline plus the scopes of the actions and triggers that the file names, and nothing more. Scope strings are opaque to this standard, and the connector’s auth.type gives them meaning.
An implementation that can read a connection’s granted scopes MUST verify the required set before any action executes. It MUST reject a file that names an action or a trigger whose scopes the connection does not grant. The diagnostic MUST name the step and the missing scope.
Note: the required set is computable from a Workfile and its manifests alone, so a tool can request exactly that set when it authorizes a connection.
Connection parameters. connection declares the values that one connection supplies beyond its credentials: a subdomain in the host, an account identifier in the path, a regional endpoint. They are configuration rather than secrets, and they differ per connection. A parameter declaration is a field declaration with three further keys.
source declares where the value comes from. user means that the account holder supplies the value when the connection is created. auth means that the authorization flow returns the value, as an instance URL arrives with an access token. The default is user. An account holder cannot supply a source: auth value, so a manifest that declares one states a fact about the authorization flow.
prompt is the text that a tool shows when it asks for a source: user value.
in declares that the parameter contributes to the address that the implementation contacts. The defined values are host and path. An absent in means that the parameter contributes to neither. This standard defines no address for any connector, so in declares the security-relevant fact alone.
A connection is complete when it holds a value for every required parameter. Implementations MUST reject a run whose resolved connection is incomplete. Implementations MUST validate every parameter value against its declared type, and against its pattern where the manifest declares one.
A parameter with in: host MUST declare a pattern, and implementations MUST reject a manifest that omits it. The pattern bounds the addresses toward which a connection can direct the connector. A connector whose address the operator chooses declares self_hosted: true at the top level of its manifest:
manifest: 1connector: ticketingversion: 1.0.0self_hosted: true
connection: base_url: { type: "string", required: true, in: host, source: user }Such a connector MAY declare a host parameter with no pattern. Implementations MUST apply a deployment-configured host policy to a self-hosted connection. By default, that policy SHOULD exclude addresses that resolve to private, loopback, or link-local ranges.
Connection parameters are not in scope for expressions. A Workfile names a connection in its triggers and steps, and never reads that connection’s configuration, so a file stays portable across the connections of its connectors.
Triggers. binds is REQUIRED on every trigger, and MUST match ^[a-z][a-z0-9_]*$. It is what allows as to be omitted.
input declares the configuration keys that an on entry supplies, as field declarations. Implementations validate the on entry against it. A trigger that declares no input takes no configuration.
correlate_on names the payload fields by which an event is matched to a suspended run.
kind is one of webhook, poll, schedule, and manual. A poll trigger owns its cursor: the position from which the next poll resumes is trigger state that the implementation holds. That state is not visible to a Workfile, and a Workfile cannot change it.
Errors. errors makes retry behavior a property of the dependency rather than a guess by the author. Each key is an error code that the connector produces. A key of the form <digit>xx matches any three-character code that begins with that digit. An exact key takes precedence over a pattern key. Each value is a map with a required class and an optional message.
class |
Meaning |
|---|---|
retryable |
Re-issuing the action can succeed. |
fatal |
Re-issuing the action cannot succeed. |
unknown |
The outcome is ambiguous, and on_unknown applies. |
An error code that the table does not name is classified unknown.
Retry. retry declares the action’s default policy. attempts is a positive integer, and counts the first attempt. backoff is one of none, fixed, and exponential. initial and max are durations. Implementations MAY apply jitter, and MUST record the delay that they applied. A step’s retry modifier overrides this policy.
rate_limit declares the vendor’s published limit. Implementations SHOULD respect it. It does not affect the result of a run.
sample names a recorded example of an action’s output or a trigger’s payload. Tools and the conformance suite use it. It is not a schema.
Promises. Manifest booleans are promises. The absence of a promise is the safe default: not idempotent, not deterministic, not validated. Every promise has a defined operational consequence.
idempotent and idempotency_param gate the checks in undo and on_unknown. Declared together, they promise idempotency per key: re-issuing the action with one key produces one effect. idempotent: true with no idempotency_param promises natural idempotency: re-issuing the action with identical arguments produces the effect of one issue. A read, a plain overwrite, and an HTTP GET are of this kind.
deterministic: true promises that the action returns the same result for identical arguments. The default is false, and a generative model, a random identifier, and a time-dependent lookup all keep it. The promise gates caching only. Implementations MAY cache or deduplicate a deterministic action’s results under an explicitly declared cache key. They MUST NOT cache the results of any other action. Recording for replay applies to every action regardless.
validated_output: true promises that the action’s result satisfies the declared output schema. Implementations MUST validate such an action’s result against the schema before they bind it. A violation is an error on that step, classified by the manifest’s errors table. Only an action with this flag MAY be the subject of an exhaustively-checked route.
Tenant-Defined Schemas
Section titled “Tenant-Defined Schemas”Parts of some connectors’ shapes are configured by the account holder rather than by the vendor: custom CRM properties, the columns of a spreadsheet, the fields of a form. A published manifest cannot declare these fields, because they differ per account. The manifest declares where such fields appear. A schema overlay declares, per connection, what they are.
Extension points. A manifest’s schemas section holds named schemas, referenced with $ref. An entry marked defined_by: connection is an extension point:
schemas: contact_properties: defined_by: connection # tenant-defined; base type map[string, json]
actions: update_contact: input: id: { type: "string", required: true } fields: { $ref: "#/schemas/contact_properties" } output: id: { type: "string" } properties: { $ref: "#/schemas/contact_properties" }An extension point’s base type is map[string, json]. connection is the only defined value of defined_by. A value whose schema is an extension point is an extension region. A trigger output MAY reference an extension point, as a spreadsheet’s row_added payload does. A vendor manifest MUST NOT declare a tenant-configured field as an ordinary typed field, because a type in a published manifest is a promise for every tenant.
Without an overlay. An extension region behaves as its base type. A field is reached by indexing, which yields null for an absent key and composes with default:
{{ contact.properties['deal_size'] | default(0) }}Member access into an extension region requires an overlay that declares the field, and implementations MUST reject member access into an unrefined region. Indexing is a dynamic lookup that yields null for an absent key; member access is a checked reference to a declared field.
Overlays. A schema overlay declares the fields of one connection’s tenant-defined schemas:
overlay: 1connector: hubspotconnection: acme-hubspot
schemas: contact_properties: deal_size: { type: "float" } lead_source: { type: "enum[web, referral, event]" }An overlay’s entries are field declarations. At most one overlay applies per connector and connection. An overlay MUST NOT declare an extension point of its own. It MUST NOT refine a schema that the manifest does not mark defined_by: connection. Implementations MUST reject an overlay that violates either rule.
Refinement. With an overlay resolved:
- Member access is statically checked. A path that names a field the overlay does not declare is a validation error. Indexing keeps its dynamic, null-on-absent behavior.
- Inputs check closed. Implementations MUST reject an argument field inside an extension region that the overlay does not declare.
- Outputs check open. On an action marked
validated_output, implementations MUST validate the region’s declared fields against their overlay types before binding. A key that the overlay does not declare binds as ajsonvalue and is not validated, which tolerates a field added upstream between overlay refreshes. - An overlay field with an enum type has a declared domain. A
routeover it is exhaustively checkable under the same condition as any other enum: the producing action declaresvalidated_output.
Scope. An overlay belongs to one connection. A binding’s shape follows the connection of the step or trigger that produced it: the environment’s default, or the step’s connection override. Static checks on a path into an extension region use the overlay of that producing connection. Two steps on different connections can therefore check against different overlays.
Resolution and drift. Implementations MUST resolve overlays when a run starts, and MUST record them by content hash. A schema edit upstream never alters a run in progress. Drift surfaces explicitly:
- A field added upstream appears in outputs as an undeclared key. It is reachable by indexing, and invisible to static checks, until the overlay is refreshed.
- A new member of an enum-typed field fails output validation on the step that observes it, classified by the manifest’s
errorstable. A checkedrouteis never handed a value outside its domain. - A field deleted upstream is caught at the next overlay refresh. Every member access that names it becomes a validation error before any run starts.
Portability. A Workfile that references only base manifests validates against the published catalog alone, and is portable across every connection of its connectors. Member access into an extension region binds the file to a connection’s overlay, and validating such a file requires the overlay for the target connection.
schema-overlays is a capability.
Catalog Boundary and Namespaces
Section titled “Catalog Boundary and Namespaces”The standard library defines the canonical connectors. Beyond it, this standard does not define which connectors exist, their names, or their contents. Vendor catalogs are published, versioned, and distributed independently.
A connector reference in a Workfile MAY be version-pinned, as helpdesk@2.1.0. When it is unpinned, the deployment environment resolves the version, and implementations MUST record the resolution.
Canonical and reserved namespaces
Section titled “Canonical and reserved namespaces”The following connector namespaces are canonical: the standard library defines their packages, and nothing else can.
| Namespace | Holds |
|---|---|
http |
Generic requests: the bridge to a system that the catalog does not yet cover. |
llm |
Model actions: categorize, extract, summarize, generate, transcribe. |
time |
Schedules. The canonical home of the schedule trigger kind. |
data |
Content operations on files: checksum, parse. |
store |
Cross-run state: get, put, compare-and-set, and atomic increment on single keys. |
wf is reserved: held for operations on workflows and runs, and not yet defined.
The canonical filter package namespaces are text, html, money, rank, workweek, human, url, markup, match, and phone. The standard library defines each, with the fixtures that the extension contract requires.
Three rules protect these names:
- An implementation MUST NOT bind a private connector or filter package to a canonical or reserved namespace.
- A catalog MUST NOT publish under one.
- Implementations MUST resolve each canonical namespace to its canonical package, at or above the pinned minimum version. A resolution that binds anything else is invalid, and the diagnostic is
catalog.reserved_namespace.
The registry lists every canonical and reserved namespace.
A Workfile that references a vendor connector is portable between two implementations that support the same capabilities and resolve the same catalog. A Workfile whose references stay within the library is portable across every implementation of the portable profile.
Static Validation
Section titled “Static Validation”Static validation
Section titled “Static validation”An implementation MUST reject a Workfile, before any action executes, for every condition that the error code registry lists outside the fault area. That registry is the single list of those conditions, and each entry names the rule it enforces. Some conditions concern a manifest or an overlay that the Workfile resolves against, rather than the Workfile itself.
Validation resolves first. An implementation MUST resolve the catalog, the connections, the overlays, the templates, and the callee of every call before it checks the conditions that depend on them.
Diagnostics MUST identify the offending construct by its path within the document, and by source location. Diagnostics MUST report the code that the registry assigns to the condition. Beyond that, presentation is implementation-defined.
Capabilities
Section titled “Capabilities”Implementations differ in what they can execute. An implementation declares the capabilities that it supports. At validation time, it MUST reject a Workfile that requires a capability it does not declare.
The registry lists every capability name. Each standard-library package has one, of the form lib-<namespace>.
Recording and Replay
Section titled “Recording and Replay”A run’s recorded history MUST be sufficient to reproduce the run exactly, without re-contacting any dependency and without reading a clock.
Implementations MUST record all of the following.
- The resolution that the run executed against.
- Every action invocation: its evaluated arguments, its outcome, the result on success, and the classified code on failure or on an ambiguous outcome.
- Every retry attempt, its outcome, and the delay that was applied.
- Every
runexecution and everyagentnode, including each action the node invoked and each result. - The branch taken at each
routeand eachchoose. Implementations MUST replay the recorded branch, and MUST NOT re-evaluate the subject. - The decision at every guard: the outcome of each
when, eachchoosearm, and eachuntil. - Every evaluation fault.
- Every
for_eachiteration and everyrepeatiteration, identified by position. - Every entry to a state, identified by position.
- Every timer that was created and the instant at which it fired.
- Every
wait_foroutcome: the event that was delivered, or the timeout, and the instant. - Every
stop_afterevaluation and the decision that followed. - The outcome of every step, and the terminal outcome of the run.
Resolution and pinning
Section titled “Resolution and pinning”Implementations MUST resolve all of the following at or before the start of a run:
- connector versions and filter package versions;
- trigger bindings;
- schema overlays, recorded by content hash;
- local template content, recorded by content hash;
- the callee of every
call; - the time zone database version;
- the format version.
Implementations MUST record the resolution for every run, whether or not the source declared it explicitly.
An implementation MAY resolve a Workfile once and then execute many runs against that one resolution. The deployment is then the unit of resolution, and a change to a catalog or to an overlay reaches a run only through an explicit re-resolution. This does not relax the recording obligation. Each run MUST record the resolution that it executed against.
A run executes against the definition resolved for it. A change to a Workfile’s source MUST NOT alter the behavior of a run that is already in progress.
Serialization
Section titled “Serialization”A Workfile is a YAML 1.2 document, restricted to the subset defined here. Connector manifests, filter package manifests, and schema overlays are YAML 1.2 documents under the same subset.
An implementation MAY accept or emit another encoding of the same data model. An implementation MUST NOT require an encoding other than YAML in order to consume a Workfile.
Within the subset:
- Anchors, aliases, tags, and multi-document streams are disallowed. Implementations MUST reject a document that uses them, rather than interpret it.
- Implicit typing is disallowed. A scalar that could be read as more than one type MUST be quoted. An implementation MUST NOT infer a type from an unquoted scalar’s shape.
- A type expression MUST be quoted wherever it appears: a Workfile’s
inputs, a state’saccepts, a manifest’s schemas, and an overlay’s fields. The type syntax is a grammar that this standard parses, and its brackets are YAML flow indicators. - Each construct has exactly one representation. There are no aliases, shorthands, or sugar forms, and no key accepts both a scalar and a single-element list.
- A duration or a timestamp appears as a quoted or unquoted scalar in a position that declares that type, and is parsed by the literal grammar.
Note: YAML 1.1 parsers read the unquoted key on as the boolean true. A Workfile’s on key is a string. An implementation MUST parse a Workfile with a YAML 1.2 parser, or MUST disable the YAML 1.1 boolean resolution for keys.
A JSON Schema describing the restricted data model is published with each spec version. It is the machine-readable form of this specification’s structural rules: the top-level keys, the one-key-per-step rule, the shape of every construct, the permitted modifiers per body kind, and the name and literal patterns.
The schema cannot check anything that needs a catalog or a scope. Path resolution, argument schemas, enum exhaustiveness, and template variables are outside it, so a document that the schema accepts is not therefore valid.
Versioning and Extensions
Section titled “Versioning and Extensions”The optional workfile key declares the major format version that a file targets. Within a major version, this standard adds only optional keys.
An unknown key is an error, not a warning.
Vendor-specific keys. A vendor-specific key MUST use the x- prefix. Implementations MUST accept a key whose name begins with x- in any map of a Workfile, a manifest, or an overlay. Implementations MUST ignore such a key when they evaluate the document, and MUST NOT let it alter the meaning of any construct. A resolved definition MUST preserve x- keys, so that a tool can read them after resolution.
Closed vocabularies. The expression grammar, the core filter set, the error vocabulary, the type system, the capability names, and the reserved namespaces are closed within a spec version. The registries state how each is amended.
A reserved namespace MAY gain a canonical manifest within a major version. A reserved namespace MUST NOT be reassigned.
Errata. A defect in this specification is corrected by an erratum against a spec version, or by a later spec version. The conformance suite is amended together with the prose. A correction MUST NOT change the meaning of a construct within a major version, except to resolve a contradiction.
Security Considerations
Section titled “Security Considerations”A Workfile composes calls to external systems, using credentials that a deployment holds, over data that often arrives from an untrusted source. The following properties are what a deployment can rely on, and what it must still supply.
Untrusted input reaches expressions. A trigger payload is attacker-influenced in many workflows. The format bounds the work that such input can cause:
matches,extract, andextract_allMUST use RE2 syntax, with no backreferences and no lookaround, and MUST match in linear time. A backtracking engine would make a pattern evaluated against inbound data a denial-of-service target.for_eachrequiresmax, andrepeatrequires a numericmax.unbounded-iterationis a separate capability, so a deployment can decline unbounded loops.calldepth and partial inclusion depth are bounded, so neither can recurse.large-payloadsis a capability, so a deployment can bound the size of the data that a run holds.
A Workfile cannot choose a request destination. Connection parameters are not in scope for expressions. A parameter that contributes to the host MUST declare a pattern, and implementations MUST reject a manifest that omits one. A connector whose address an operator chooses declares self_hosted: true, and implementations MUST apply a deployment-configured host policy to it. That policy SHOULD exclude addresses that resolve to private, loopback, or link-local ranges. No value inside a Workfile can therefore redirect a credentialed request.
Credentials stay outside run state. A Workfile names a connection and never reads it. Credentials are not readable from any expression, and implementations MUST NOT record credential material in a run record. A recorded action invocation holds the evaluated arguments and the connection name.
Least privilege is computable. A manifest declares scopes per action and per trigger. The set that a file requires is the baseline plus the scopes of the actions and triggers that it names. An implementation that can read a connection’s granted scopes MUST verify that set before any action executes.
Composed output is escaped by construction. A render template escapes every interpolation according to its context, and there is no opt-out. The text context escapes nothing, so an author who renders untrusted data into markup MUST use an html or json context. For strings composed outside a template, the escaping filters cover HTML, JSON, URL, and shell contexts.
Sandboxed code is bounded but reads the whole scope. A run step cannot invoke an action, cannot schedule work, cannot read the filesystem, and has no network egress by default. Its ctx is the full readable scope, so a run step with a network grant can send any readable binding to a granted host. A deployment that treats run state as sensitive SHOULD decline the run-python and run-javascript capabilities, or SHOULD review every network grant.
Autonomy is bounded and recorded. An agent node reads exactly its with projection and its rendered goal. Implementations MUST refuse an action outside the node’s tools allowlist, MUST validate every invocation against its manifest, and MUST record every invocation. budget is required, so a node cannot run without a stated bound.
A run record holds payload data. Recording is mandatory and complete, so a record accumulates the arguments and the results of every action. A deployment MUST apply its own retention policy and access controls to run records. This standard defines no storage format and no redaction mechanism.
Manifests and packages are trusted input. A validator acts on a manifest’s promises: idempotent gates re-issue, validated_output gates exhaustive dispatch, and an error table decides what is retried. A false promise is not detectable from the file that relies on it. A deployment SHOULD resolve only from catalogs that it trusts, and MUST record the resolution for every run. A filter package executes as code in the expression layer, and implementations MUST verify its fixtures before they load it.
Ambiguous outcomes default to a stop. Where an action’s outcome is unknown and the manifest makes no idempotency promise, the default is halt rather than re-issue. A duplicate side effect requires an explicit promise plus an explicit key.
Conformance
Section titled “Conformance”The specification is its pages and annexes together with the conformance suite. Both are normative. Where they disagree, the specification has a defect. Implementations then follow the suite provisionally, and the maintainers resolve the disagreement explicitly. A reference engine is an implementation like any other: where it disagrees with the suite, the engine is wrong.
The suite is language-agnostic data: Workfiles, connector and filter manifests, fixture inputs, and expected results, step traces, and error codes.
The suite distributes the standard library’s canonical manifests and fixtures, under library/. Vendor connectors are not mandated, so the suite also ships its own test connectors: fully specified manifests that exercise every path an implementation must handle. They cover idempotent and non-idempotent actions, each error classification, an action that returns an ambiguous outcome, a deterministic action, a validated_output action, and one trigger of each kind.
Levels
Section titled “Levels”An implementation conforms at a stated level and capability set.
- Level 1 — Validator. Parses the serialization subset, resolves against supplied manifests and overlays, and reports every condition of static validation with the correct error code and source location.
- Level 2 — Executor. Level 1, plus executing the test connectors with correct ordering, skip, failure, recovery, and undo semantics, and correct evaluation of the full core filter set.
- Level 3 — Durable. Level 2, plus checkpointing, resumption, and replay, as recording and replay requires, across the capabilities that it declares.
- Portable profile. Level 2 or higher, with every standard-library capability declared. A Workfile whose references stay within the library runs on every implementation of this profile.
An implementation MUST state its level and its capability set together. A capability that an implementation does not declare is not a conformance failure. Executing a construct that requires an undeclared capability is a conformance failure.
The suite
Section titled “The suite”The suite is a directory of data files. Its layout is normative, so that one suite runs against every implementation.
conformance/ validation/ one case per file: a document and the codes it must produce expressions/ expression fixtures, as JSON Lines connectors/ the test connector manifests library/ the standard library: canonical manifests, fixtures, and samples traces/ execution cases: a workflow, canned action results, expected outcomesA validation case is a YAML document with the keys case, expect, and workfile. expect is a list of the error codes that an implementation MUST report. An empty expect asserts that the document is valid.
workfile holds the document as parsed YAML. A case that tests the serialization subset instead holds workfile as a string, whose text is the raw document. The condition of such a case does not survive parsing.
A case MAY supply the following keys beside the document.
manifests— connector and filter package manifests, resolved in addition to the suite’s test connectors and the library’s canonical packages.overlays— schema overlays to resolve.connections— named connections, each withconnector,granted_scopes, andparameters. Without this key, every connector resolves through one complete connection, nameddefault, that grants every scope.files— a map of project-relative path to file content, forcallcallees and for templates.capabilities— the capability set to validate under. Without this key, every capability is declared.
A case whose codes require a catalog, a connection, a file, or a scope is marked needs: resolution. A checker that applies only the published JSON Schema skips such a case.
case: route over a validated enum leaves a member uncoveredexpect: [flow.route_not_exhaustive]workfile: steps: [ ... ]An implementation passes a case when the set of codes that it reports equals expect. Message text is not compared.
An expression fixture is one JSON object per line, holding expr, an optional bindings map, and exactly one of result and fault.
{"expr": "{{ 7 / 2 }}", "result": 3.5}{"expr": "{{ 1 / 0 }}", "fault": "fault.division_by_zero"}A trace case supplies a workflow, a result for each action invocation, and the expected outcome of every step together with the run’s terminal outcome. It is how Level 2 and Level 3 are tested.
Canned results appear in actions. Entries are matched by step path, and the entries of one path are consumed in attempt order, so a retried step consumes one entry per attempt. A canned outcome is success, failure, or unknown — an ambiguous attempt. A step inside a for_each iteration, a repeat iteration, or a state entry is addressed by position: poll[0].probe, fetch[1].page. A reconcile step is addressed as <step>.reconcile.<id>, and an undo as <step>.undo. A case MAY supply events: the outcome of each wait_for, in order — a delivered payload, or a timeout. Its expect MAY also state bindings, outputs, and codes, the classified code per failed step. A trace of a run that ends held states held_at: the step and its classified code. Its steps then holds only the steps that reached an outcome.
A requirement of this specification that no case exercises is untested, and the suite is incomplete for it.
Advancing this standard
Section titled “Advancing this standard”A construct advances from draft to stable when two implementations that share no code pass the suite for it. Independent interoperation is the evidence that the prose is sufficient. A construct that one implementation supports is a proposal, whatever its merits.
Use of the name
Section titled “Use of the name”An implementation can use the Workfile name only if it passes the suite at a declared level. This is a naming policy rather than a technical requirement.
Core Filters
Section titled “Core Filters”This annex is the core filter set. It is closed within a spec version. Implementations MUST provide every filter here, and MUST NOT add a filter with a bare name. A bracketed argument is optional. T? denotes a value that can be null.
Filter behavior is defined by this annex together with the conformance suite fixtures. Where a description defers to fixtures, the fixtures are normative.
Selection and projection
Section titled “Selection and projection”The filter rules govern these four filters, including the current binding and the closed operator set of where.
| Filter | Signature |
|---|---|
where(path, op, value) |
list[object] → list[object] — keeps elements whose path satisfies the comparison |
keep(expr) |
list[T] → list[T] — keeps elements for which expr is true, with current bound to the element |
each(expr) |
list[T] → list[U] — evaluates expr once per element, with current bound to the element |
has(path) |
object → bool — the path exists and is non-null |
Collections
Section titled “Collections”| Filter | Signature and behavior |
|---|---|
length |
list | map | string → int — elements, entries, or code points |
default(v) |
T? → T — yields v when the value is null |
first / last |
list[T] → T? — null for an empty list |
slice(a, b) |
list → list — half-open, and a negative index counts from the end |
reverse |
list → list |
keys / values |
map[string, T] → list — in the map’s key order |
items |
map[string, T] → list[object] — each element is { key: string, value: T } |
flatten |
list[list[T]] → list[T] — one level |
batch(n) |
list[T] → list[list[T]] — chunks of at most n; n MUST be a positive integer |
group_by(path) |
list[object] → map[string, list[object]] — the value at path MUST be a string |
sort |
list → list — by the standard ordering |
sort_by(path) |
list[object] → list[object] — by the standard ordering, stable |
unique |
list → list — keeps the first occurrence of each value, in order |
sum / min / max |
list[number] → number — sum of an empty list is 0; min and max of an empty list yield null |
join(sep) |
list[string] → string |
Strings
Section titled “Strings”| Filter | Signature and behavior |
|---|---|
split(sep, [limit]) |
string → list[string] — limit is the maximum number of elements, and the last holds the unsplit remainder; it MUST be a positive integer |
lower / upper |
string → string — Unicode simple case mapping, independent of locale |
trim |
string → string — removes leading and trailing Unicode whitespace |
capitalize |
string → string — uppercases the first cased character, and lowercases every other cased character |
title |
string → string — uppercases the first cased character of each word, and lowercases every other cased character; a word is a maximal run of characters that are not Unicode whitespace |
starts_with(s) / ends_with(s) |
string → bool |
matches(re) |
string → bool |
extract(re) |
string → string? — the first capture group, or the whole match when the pattern has no group; null when unmatched |
extract_all(re) |
string → list[string] — every non-overlapping match, taking the first capture group of each, or the whole match when the pattern has no group; an empty list when unmatched |
replace(a, b) |
string → string — every occurrence; a is a literal, not a pattern |
format(fmt) |
list → string — each {} in fmt consumes the next element; {{ and }} are literal braces; an arity mismatch is an evaluation fault |
truncate(n) |
string → string — at most n code points; no marker is appended |
indent(n) |
string → string — prefixes every line with n spaces |
wordwrap(n) |
string → string — breaks lines at whitespace so that no line exceeds n code points; a word longer than n occupies its own line and is not broken |
pluralize(one, many) |
int → string — yields one when the value is 1, and many otherwise |
human_size |
int → string — a byte count as text; see below |
strip_html |
string → string — see below |
matches, extract, and extract_all MUST use RE2 syntax: no backreferences, no lookaround, and linear-time matching. Security considerations states why.
human_size scales by 1000, with the units B, kB, MB, GB, TB, and PB. It selects the largest unit for which the magnitude is at least 1. It formats with one decimal place, except for B, which has none. One space separates the number from the unit. 1500 yields 1.5 kB, and 900 yields 900 B.
strip_html removes every tag, and removes the content of script and style elements. It decodes named and numeric character references. It then replaces each run of whitespace with one space, and trims.
Numbers and conversion
Section titled “Numbers and conversion”| Filter | Signature and behavior |
|---|---|
abs |
number → number |
round(n) |
number → number — to n decimal places, half away from zero |
int |
T → int — from a decimal string with an optional sign, or from a float whose fractional part is zero; every other input is an evaluation fault |
float |
T → float — from an int, or from a decimal string |
string |
T → string — from a scalar: null yields the empty string, a bool yields true or false, a timestamp yields RFC 3339 in UTC, and a duration yields its canonical literal; a list or a map is an evaluation fault |
bool |
T → bool — from a bool, or from the strings true and false; every other input is an evaluation fault |
timestamp |
string → timestamp — from RFC 3339 with a mandatory offset; the display zone is the literal’s offset; every other input is an evaluation fault |
duration |
string → duration — from the duration literal grammar; every other input is an evaluation fault |
json |
any → string — canonical serialization: sorted keys, no insignificant whitespace, UTF-8 |
from_json |
string → json — parses one JSON document (RFC 8259); the result is typed json; an input that is not valid JSON, or an object with a repeated member name, is an evaluation fault |
timestamp and duration are the inverses of string on those types, and from_json is the inverse of json, so a value survives a round trip through text. The grammar states the separate rule that a position which declares the type timestamp accepts an RFC 3339 string without a filter.
| Filter | Signature and behavior |
|---|---|
plus(duration) / minus(duration) |
timestamp → timestamp — preserves the display zone |
diff(timestamp) |
timestamp → duration — the receiver minus the argument; the result can be negative |
format_time(fmt) |
timestamp → string — in the display zone; see the directive set below |
to_timezone(tz) |
timestamp → timestamp — changes the display zone only; tz is an IANA identifier or a fixed offset |
format_time directives. The set is closed. A character outside a directive is copied literally. An unrecognized directive is a validation error where fmt is a literal, and an evaluation fault otherwise.
| Directive | Yields |
|---|---|
%Y |
Year, at least four digits |
%m / %d |
Month 01–12 / day of month 01–31 |
%j |
Day of year, 001–366 |
%H / %M / %S |
Hour 00–23 / minute 00–59 / second 00–59 |
%I / %p |
Hour 01–12 / AM or PM |
%L |
Millisecond, 000–999 |
%z / %:z |
Offset from UTC, as +HHMM / as +HH:MM |
%b / %B |
Month name, abbreviated / full |
%a / %A |
Weekday name, abbreviated / full |
%% |
A literal % |
Month and weekday names are English: January to December, Monday to Sunday, and their first three characters as the abbreviation. A name in another language belongs in an extension package, where fixtures verify it.
Escaping and hashing
Section titled “Escaping and hashing”These filters exist so that an author does not write escaping by hand.
| Filter | Signature |
|---|---|
html_escape |
string → string |
json_escape |
string → string |
url_encode |
string → string |
shell_quote |
string → string |
md5 / sha256 |
string → string — lowercase hexadecimal |
base64 |
string → string — the standard alphabet, with padding |
Excluded by contract
Section titled “Excluded by contract”now, random, uuid, and every other source of an ambient or nondeterministic value are not filters, and MUST NOT be added as extensions, because they violate purity. The current instant is run.started_at, a recorded binding rather than a value synthesized inside an expression.
There is no count, no drop, no where_not, and no two-argument equality form of where. length, keep(not …), and where('status', '!=', 'open') are the one representation of each.
Template-autoescaping machinery such as safe and force_escape has no analogue here. In expressions there is no output document to autoescape, and in local templates escaping is contextual and mandatory.
There is no hmac and no other signing filter, and no canonical package will hold one. A signature requires a key, a key is a credential, and no expression can read a credential. An action that requires a signed request receives the signature from its connector’s executor.
slugify is left out of core, because implementations diverge silently on its Unicode behavior. The canonical text package defines it, with fixtures that pin the behavior.
relative_to is left out of core for the same reason: a human-readable interval is wording, and wording is a locale. The canonical human package defines it, with the locale as an explicit argument.
Format-directed time parsing is left out of core and out of the current library. The timestamp filter converts RFC 3339, which is unambiguous. A parse directed by a pattern such as %d/%m/%Y needs a locale, a century rule for a two-digit year, and a policy for an impossible date. Each is a place where implementations differ without saying so.
Grammar
Section titled “Grammar”This annex uses ABNF (RFC 5234). WSP, DIGIT, HEXDIG, and DQUOTE are the core rules of that document.
Interpolation
Section titled “Interpolation”interpolation = "{{" *WSP expression *WSP "}}"The typing rules state how a scalar that holds interpolations yields a value.
Expressions
Section titled “Expressions”expression = conditionalconditional = or-expr [ 1*WSP "if" 1*WSP or-expr 1*WSP "else" 1*WSP conditional ]or-expr = and-expr *( 1*WSP "or" 1*WSP and-expr )and-expr = equality *( 1*WSP "and" 1*WSP equality )equality = relational [ ( *WSP eq-op *WSP / 1*WSP "in" 1*WSP ) relational ]eq-op = "==" / "!="relational = additive [ *WSP rel-op *WSP additive ]rel-op = "<=" / ">=" / "<" / ">"additive = multiplicative *( *WSP add-op *WSP multiplicative )add-op = "+" / "-"multiplicative = unary *( *WSP mul-op *WSP unary )mul-op = "*" / "/" / "%"unary = ( "not" 1*WSP unary ) / ( "-" unary ) / postfixpostfix = primary *( member / index / filter )member = "." nameindex = "[" *WSP expression *WSP "]"filter = *WSP "|" *WSP filter-name [ "(" *WSP [ arg-list ] *WSP ")" ]filter-name = name [ "." name ]arg-list = expression *( *WSP "," *WSP expression )primary = literal / name / list-lit / map-lit / groupgroup = "(" *WSP expression *WSP ")"list-lit = "[" *WSP [ expression *( *WSP "," *WSP expression ) ] *WSP "]"map-lit = "{" *WSP [ map-entry *( *WSP "," *WSP map-entry ) ] *WSP "}"map-entry = ( string-lit / name ) *WSP ":" *WSP expressionequality and relational are non-associative: a < b < c is a syntax error, and parentheses express the intended grouping.
A filter-name with one component names a core filter. A filter-name with two components names an extension filter.
literal = string-lit / duration-lit / number-lit / bool-lit / "null"bool-lit = "true" / "false"
name = %x61-7A *( %x61-7A / DIGIT / "_" )
string-lit = DQUOTE *dq-char DQUOTE / "'" *sq-char "'"dq-char = %x20-21 / %x23-5B / %x5D-10FFFF / escapesq-char = %x20-26 / %x28-5B / %x5D-10FFFF / escapeescape = "\" ( "\" / DQUOTE / "'" / "n" / "r" / "t" / unicode-esc )unicode-esc = "u{" 1*6HEXDIG "}"
number-lit = float-lit / int-litint-lit = 1*DIGITfloat-lit = 1*DIGIT "." 1*DIGIT [ exponent ] / 1*DIGIT exponentexponent = ( "e" / "E" ) [ "+" / "-" ] 1*DIGITA term is matched by the longest alternative that succeeds. 1 is an int-lit, and 1d is a duration-lit.
A negative number is the unary - operator applied to a literal. There is no negative literal.
Durations
Section titled “Durations”duration-lit = 1*duration-partduration-part = 1*DIGIT duration-unitduration-unit = "ms" / "s" / "m" / "h" / "d"Units MUST appear in strictly descending order of magnitude, and each unit MUST appear at most once. 1h30m and 500ms are valid. 30m1h and 1h1h are not.
A day is exactly 86400000 milliseconds, and has no calendar or daylight-saving meaning. A duration literal is never negative.
Timestamps
Section titled “Timestamps”A timestamp is written as an RFC 3339 date-time with a mandatory time offset. A second value of 60 MUST be rejected.
There is no timestamp term in the expression grammar. Where a value of type timestamp is expected, implementations MUST accept a string in this form and convert it.
Type expressions
Section titled “Type expressions”type-expr = "string" / "int" / "float" / "bool" / "timestamp" / "duration" / "file" / "json" / enum-type / list-type / map-typeenum-type = "enum[" member *( "," *WSP member ) "]"member = 1*( %x61-7A / DIGIT / "_" )list-type = "list[" type-expr "]"map-type = "map[string," *WSP type-expr "]"An object type is expressed as JSON Schema rather than as a type expression. The serialization rules require that a type expression is always quoted.
Registries
Section titled “Registries”The vocabularies below are open-ended. Each is a registry rather than a fixed part of the grammar, so that it can grow without a change to the format. A registry entry is never removed, and never reassigned to another meaning.
An entry is added by a spec revision within a major version. An addition MUST NOT change the meaning of an existing entry.
Error codes
Section titled “Error codes”The error code registry is normative. Every condition of static validation and every evaluation fault has one code there, of the form <area>.<condition>.
Capability names
Section titled “Capability names”durable-timers, signals, sub-workflows, parallel, large-payloads, queue-by, batching, run-python, run-javascript, agent-nodes, states-profile, schema-overlays, unbounded-iteration, lib-time, lib-store, lib-http, lib-data, lib-llm, lib-text, lib-html, lib-money, lib-rank, lib-workweek, lib-human, lib-url, lib-markup, lib-match, lib-phone.
Connector namespaces
Section titled “Connector namespaces”Canonical: http, llm, time, data, store. Reserved: wf. The standard library defines each canonical namespace, and the namespace rules protect them.
Filter package namespaces
Section titled “Filter package namespaces”Canonical: text, html, money, rank, workweek, human, url, markup, match, phone. The standard library defines each, under the same namespace rules.
run languages
Section titled “run languages”python and javascript, for a run step. Each registered language has a capability name of the form run-<language>, among the capability names above.
Manifest Key Reference
Section titled “Manifest Key Reference”This annex indexes every key of a connector manifest, with its operational consequence, for a connector author and for a manifest linter. The connector manifest section is the definition.
An implementation MUST reject a manifest that holds a key which this annex does not list, unless the key begins with x-.
Top level
Section titled “Top level”| Key | Type | Required | Consequence |
|---|---|---|---|
manifest |
int |
yes | The manifest format version. An implementation that does not support it rejects the manifest. |
connector |
name | yes | The namespace of every connector.action and connector.event reference. |
version |
semver | yes | What a version-pinned reference selects, and what the resolution records. |
self_hosted |
bool |
no | Permits a host parameter with no pattern, and requires the deployment host policy. |
auth |
map | yes | See auth. |
connection |
map | no | See connection.<name>. |
schemas |
map | no | Named schemas, referenced by $ref. See schemas.<name>. |
actions |
map | no | See actions.<name>. |
triggers |
map | no | See triggers.<name>. |
| Key | Type | Required | Consequence |
|---|---|---|---|
type |
name | yes | Gives meaning to every scope string. Opaque to this standard. |
scopes |
list[string] |
no | The baseline that every action and trigger requires. |
connection.<name>
Section titled “connection.<name>”A field declaration, plus:
| Key | Type | Required | Consequence |
|---|---|---|---|
source |
enum[user, auth] |
no | user means the account holder supplies the value. auth means the authorization flow returns it. Default user. |
prompt |
string |
no | The text shown when a source: user value is requested. |
in |
enum[host, path] |
no | Declares that the parameter contributes to the address. in: host requires pattern, unless the manifest declares self_hosted. |
actions.<name>
Section titled “actions.<name>”| Key | Type | Required | Consequence |
|---|---|---|---|
scopes |
list[string] |
no | Added to the baseline for this action alone. Verified against the connection before execution. |
input |
map of field declarations | no | Arguments are validated against it before dispatch. |
output |
map of field declarations | no | The shape that the step’s binding takes, and what expression paths resolve against. |
idempotent |
bool |
no | Permits on_unknown: retry, and satisfies the redo-set rule. Default false. |
idempotency_param |
name | no | The argument that a step’s idempotency_key supplies. Absent, idempotent: true promises natural idempotency. |
deterministic |
bool |
no | Permits caching under a declared cache key, and nothing else. Default false. |
validated_output |
bool |
no | Requires output validation before binding, and permits an exhaustively-checked route over an enum output. Default false. |
rate_limit |
{ rps, burst } |
no | Advisory. Implementations SHOULD respect it. It does not affect a result. |
retry |
map | no | The default retry policy. See retry and errors. |
errors |
map | no | Classifies each producible code. See retry and errors. |
sample |
path | no | A recorded example output, for tooling and the suite. It is not a schema. |
triggers.<name>
Section titled “triggers.<name>”| Key | Type | Required | Consequence |
|---|---|---|---|
kind |
enum[webhook, poll, schedule, manual] |
yes | Selects the trigger machinery. A poll trigger owns its cursor. |
binds |
name | yes | The default binding name, which lets a file omit as. |
input |
map of field declarations | no | The configuration keys of an on entry are validated against it. |
correlate_on |
list[name] |
no | The fields that a wait_for or a subscription supplies in match. Without it, no suspended run can await this event. |
scopes |
list[string] |
no | Added to the baseline for this trigger alone. |
output |
map or $ref |
no | The payload shape that expressions resolve against. |
sample |
path | no | A recorded example payload. |
retry and errors
Section titled “retry and errors”| Key | Type | Required | Consequence |
|---|---|---|---|
retry.attempts |
positive int |
yes | Total attempts, counting the first. |
retry.backoff |
enum[none, fixed, exponential] |
yes | The delay schedule. |
retry.initial / retry.max |
duration |
no | The first delay and the ceiling. |
errors.<code>.class |
enum[retryable, fatal, unknown] |
yes | Decides whether a failure is re-issued, is final, or invokes on_unknown. |
errors.<code>.message |
string |
no | Diagnostic text for the author. |
An errors key is an exact code, or a pattern of the form <digit>xx. An exact key takes precedence. An unlisted code is classified unknown.
schemas.<name>
Section titled “schemas.<name>”| Key | Type | Required | Consequence |
|---|---|---|---|
defined_by |
enum[connection] |
no | Marks the entry as an extension point. A value of that schema is an extension region, refined per connection by an overlay. |
An entry without defined_by is an ordinary named schema, expressed as JSON Schema draft 2020-12.
Error Codes
Section titled “Error Codes”Status: Draft · Spec version 0.6 · Normative
This document is the error code registry that the specification requires. It lists every condition of static validation and every evaluation fault, with the code that a diagnostic reports for it.
The scheme
Section titled “The scheme”A code has the form <area>.<condition>. Both parts match ^[a-z][a-z0-9_]*$.
expr.unresolved_pathcatalog.missing_scopefault.division_by_zeroAreas.
| Area | Conditions in |
|---|---|
doc |
Document structure, names, and modifiers |
serial |
The serialization subset |
expr |
Expression syntax, resolution, and typing |
filter |
Filters and filter packages |
flow |
Control flow |
tmpl |
Templates |
state |
The states profile |
trigger |
Triggers |
catalog |
Manifests, connections, and overlays |
recovery |
Failure and recovery policy |
agent |
Agent nodes |
run |
Sandboxed code |
capability |
Declared capabilities |
fault |
Evaluation faults, which occur at run time |
Stability. A code is never removed, and never reassigned to another condition. A condition that a later spec version splits keeps the original code for the original condition, and the new part takes a new code.
Allocation. A code is added by a spec revision, together with the requirement that produces it. A requirement without a code is a defect in this document.
Reporting. A diagnostic MUST report one code, the path of the offending construct within the document, and a source location. Message text is implementation-defined. An implementation MAY report more than one diagnostic for one document.
Every code below is a validation error, reported before any action executes, except the fault area. A fault code names a condition that occurs during evaluation, and is recorded as a step outcome.
doc — document structure
Section titled “doc — document structure”| Code | Condition |
|---|---|
doc.unknown_key |
An unknown top-level key or an unknown construct key. A key beginning with x- is never unknown. |
doc.unknown_modifier |
A modifier that the modifier table does not permit on that body kind. |
doc.missing_body |
The file declares neither steps nor states. |
doc.both_bodies |
The file declares both steps and states. |
doc.states_without_initial |
A states file that does not declare initial. |
doc.initial_without_states |
A steps file that declares initial. |
doc.duplicate_id |
Two steps in one scope share an id. |
doc.reserved_name |
A step id, as binding, into binding, outputs name, or accepts name repeats a reserved name. |
doc.optional_with_must_succeed |
One step declares both optional and must_succeed. |
doc.invalid_name |
A declared name does not match ^[a-z][a-z0-9_]*$. |
serial — serialization
Section titled “serial — serialization”| Code | Condition |
|---|---|
serial.anchor_or_alias |
The document uses a YAML anchor or alias. |
serial.tag |
The document uses a YAML tag. |
serial.multi_document |
The stream holds more than one document. |
serial.implicit_type |
An unquoted scalar could be read as more than one type. |
serial.unquoted_type_expression |
A type expression appears unquoted. |
expr — expressions
Section titled “expr — expressions”| Code | Condition |
|---|---|
expr.syntax |
The expression violates the expression grammar. |
expr.chained_comparison |
A comparison is chained, as a < b < c. Comparison is non-associative. |
expr.unresolved_path |
A path names no binding in scope at that point. |
expr.non_boolean_guard |
A guard position holds an expression whose type is known and is not bool. |
expr.invalid_duration_literal |
A duration literal violates the duration grammar. |
expr.invalid_timestamp_literal |
A timestamp violates the timestamp grammar. |
filter — filters and packages
Section titled “filter — filters and packages”| Code | Condition |
|---|---|
filter.unknown |
The name matches no core filter and no filter of a resolved package. |
filter.signature |
An argument violates the filter’s declared signature. |
filter.package_unavailable |
A referenced filter package cannot be resolved. |
filter.package_missing_fixtures |
A filter package does not ship fixtures for every filter. |
filter.where_op_not_literal |
The op argument of where is not a string literal. |
filter.where_op_unknown |
The op argument of where is outside its closed set. |
filter.nested_element_filter |
The argument of each or keep holds an each or a keep. |
filter.current_out_of_scope |
current is referenced outside the argument of each or keep. |
filter.unknown_time_directive |
A literal format_time pattern holds a directive outside the closed directive set. |
flow — control flow
Section titled “flow — control flow”| Code | Condition |
|---|---|
flow.route_not_exhaustive |
A route over a value with a declared enum domain leaves a member uncovered. |
flow.route_label_out_of_domain |
A route case label is outside the declared enum domain. |
flow.missing_else |
A route or a choose declares no else. |
flow.missing_max |
A for_each or a repeat declares no max. |
flow.invalid_max |
max is neither a positive integer nor unbounded. |
flow.unbounded_repeat |
A repeat declares max: unbounded. |
flow.invalid_on_error |
An on_error value other than continue or fail_fast. |
flow.duplicate_branch_name |
Two parallel branches share a name. |
flow.cross_branch_reference |
A parallel branch references another branch’s bindings. |
flow.wait_arms |
A wait declares neither or both of for and until. |
flow.wait_for_without_timeout |
A wait_for declares no timeout. |
flow.missing_correlation_value |
A match omits a value for a key that correlate_on declares. |
flow.path_into_void_step |
A path names the id of a wait step or a stop step, which bind nothing. |
flow.call_missing_input |
A call omits an input that the callee declares required. |
flow.call_unknown_input |
A call supplies a name that the callee does not declare. |
flow.call_unknown_output |
A path names an output that the callee does not declare. |
flow.call_without_outputs |
A path enters a call on a callee that declares no outputs. |
flow.call_depth |
The callee of a call itself contains a call. |
tmpl — templates
Section titled “tmpl — templates”| Code | Condition |
|---|---|
tmpl.value_extra_key |
A value step holds a key other than value and when. |
tmpl.render_extra_key |
A render step holds a key other than render, with, and when. |
tmpl.path_not_literal |
A render path holds an expression. |
tmpl.path_not_found |
A render path names no file and no directory. |
tmpl.inline_body_keys |
An inline render map does not hold exactly one of text, html, json, parts. |
tmpl.duplicate_part |
Two parts resolve to one name. |
tmpl.invalid_part_name |
A part name does not match ^[a-z][a-z0-9_]*$. |
tmpl.variable_unsupplied |
The body references a variable that is unguarded and that with does not supply. |
tmpl.variable_unused |
with supplies a key that the body never references. |
tmpl.syntax |
The body violates the template block grammar. |
tmpl.partial_context_mismatch |
A partial is included from a template of another escape context. |
tmpl.partial_nesting |
A partial includes another partial. |
tmpl.partial_cycle |
A partial includes itself, directly or transitively. |
state — the states profile
Section titled “state — the states profile”| Code | Condition |
|---|---|
state.subscription_without_event |
An on entry declares no event. |
state.transition_without_target |
A transition declares neither goto nor route. |
state.unknown_state |
initial or a goto names a state that is not declared. |
state.no_final_state_reachable |
No final state is reachable from the initial state. |
state.accepts_missing_required |
A transition omits a name that the target’s accepts declares required. |
state.accepts_unknown_name |
A transition supplies a name that the target’s accepts does not declare. |
state.accepts_on_initial |
The initial state declares accepts. |
trigger — triggers
Section titled “trigger — triggers”| Code | Condition |
|---|---|
trigger.binding_collision |
A trigger binding repeats a root-scope step id, and no as resolves it. |
trigger.missing_as |
A multi-trigger file has an entry without as. |
trigger.into_on_single_trigger |
A single-trigger file declares into. |
trigger.into_incomplete |
Some entries of a multi-trigger declaration declare into and others do not. |
trigger.into_shape_mismatch |
Two into entries do not bind an identical key structure. |
trigger.batch_with_queue_by |
One trigger entry declares both batch and queue_by. |
catalog — manifests, connections, overlays
Section titled “catalog — manifests, connections, overlays”| Code | Condition |
|---|---|
catalog.unknown_connector |
A reference names a connector that the resolved catalog does not declare. |
catalog.unknown_action |
A reference names an action that the connector’s manifest does not declare. |
catalog.unknown_event |
A reference names an event that the connector’s manifest does not declare. |
catalog.invalid_argument |
An argument violates the input schema of the named action or trigger. |
catalog.reserved_namespace |
A resolved catalog binds a package other than the canonical one to a canonical or reserved namespace. |
catalog.missing_scope |
The resolved connection does not grant a scope that a named action or trigger declares. |
catalog.incomplete_connection |
The resolved connection holds no value for a required connection parameter. |
catalog.invalid_connection_parameter |
A connection parameter value violates its declared type or its pattern. |
catalog.host_parameter_without_pattern |
A manifest declares in: host with no pattern, and does not declare self_hosted. |
catalog.external_ref |
A $ref is not a local pointer of the form #/schemas/<name>. |
catalog.manifest_unknown_key |
A manifest holds a key that the manifest key reference does not list. |
catalog.field_declaration_invalid |
A field declaration holds an unknown key, declares both type and $ref, declares neither, or declares both required: true and default. |
catalog.unrefined_member_access |
Member access enters an extension region for which no overlay is resolved. |
catalog.overlay_unknown_field |
Member access names a field that the resolved overlay does not declare. |
catalog.overlay_undeclared_argument |
An argument field inside an extension region is not declared by the overlay. |
catalog.overlay_declares_extension_point |
An overlay declares an extension point of its own. |
catalog.overlay_refines_ordinary_schema |
An overlay refines a schema that the manifest does not mark defined_by: connection. |
recovery — failure and recovery
Section titled “recovery — failure and recovery”| Code | Condition |
|---|---|
recovery.retry_on_non_idempotent |
on_unknown: retry on an action that the manifest does not declare idempotent. |
recovery.retry_without_key |
on_unknown: retry on a step that supplies no idempotency_key, while the action declares an idempotency_param. |
recovery.reconcile_without_policy |
A reconcile key on a step whose on_unknown is not reconcile. |
recovery.reconcile_incomplete |
An on_unknown: reconcile step whose reconcile is absent, omits steps or outcome, or omits result while the action declares an output. |
recovery.redo_set_uncompensated |
A redo set holds a step that is not naturally idempotent, is not idempotent with a declared key, and does not declare undo. |
agent and run
Section titled “agent and run”| Code | Condition |
|---|---|
agent.missing_budget |
An agent declares no budget. |
agent.empty_budget |
A budget declares no defined bound. |
agent.tool_not_in_catalog |
tools names an action outside the resolved catalog. |
run.unregistered_language |
The language of a run step is not in the registry of run languages. |
capability
Section titled “capability”| Code | Condition |
|---|---|
capability.required |
The file uses a construct that requires a capability the implementation does not declare. The diagnostic MUST name the capability. |
fault — evaluation faults
Section titled “fault — evaluation faults”These occur during evaluation, not during validation. The fault rules state how each is handled.
| Code | Condition |
|---|---|
fault.division_by_zero |
Division or modulo by zero. |
fault.integer_overflow |
An integer result outside the int range. |
fault.operand_type |
An operator applied to a value outside its domain: arithmetic on a non-number, % on a non-integer, or not, and, or on a non-boolean. |
fault.non_finite |
An operation that would produce NaN or an infinity. |
fault.ordered_comparison_types |
An ordered comparison whose operands are not two values of one ordered type, including a comparison against null. |
fault.mixed_ordering |
An ordering filter applied to a collection that mixes ordered types. |
fault.membership_operand |
The right operand of in is not a list, a map, or a string. |
fault.conversion |
A conversion filter cannot convert a value unambiguously. |
fault.filter_signature |
A filter is applied outside its declared signature, where the input’s type could not be established statically. |
fault.filter_argument |
A computed filter argument is invalid at run time. |
fault.unknown_time_directive |
A computed format_time pattern holds a directive outside the closed set. |
fault.non_boolean_guard |
A guard yields a value that is not a boolean, where the type could not be established statically. |
fault.template_render |
An interpolation in a template body faults. |
Standard Library
Section titled “Standard Library”Status: Draft · Spec version 0.6 · Normative · Companion to the Workfile specification
This document defines the standard library: the canonical connectors and the canonical filter packages of the specification’s canonical namespaces. For these namespaces, portability is a guarantee of the standard rather than a property of a shared deployment catalog.
Form and authority
Section titled “Form and authority”The library is three kinds of artifact together:
- this document, which states the behavior that a manifest cannot state;
- the canonical manifests: data files in the standard’s own formats, a connector manifest or a filter package manifest, under
conformance/library/; - the fixtures and samples that ship beside each manifest.
All three are normative. A manifest defines a surface: names, argument schemas, error classes, and promises. Fixtures verify behavior. Where the three disagree, the standard has a defect, and the maintainers resolve it explicitly, as the conformance rules provide for the suite.
The library defines no vendor connector, no address, no wire protocol, and no model provider. Vendor catalogs are published and versioned independently. Where a library connector performs network or model I/O, the deployment supplies the destination through a connection.
Versions and pinning
Section titled “Versions and pinning”Each library package has its own version, independent of the spec version. This spec version pins the minimum version of each package:
| Package | Kind | Minimum version |
|---|---|---|
time, store, http, data, llm |
connector | 1.0.0 |
text, html, money, rank, workweek, human, url, markup, match, phone |
filters | 1.0.0 |
Three rules keep the pin meaningful:
- Within a major version, a library package MUST NOT remove a name, change the type of an argument or a result, or weaken a promise. A minor version MAY add an action, a filter, or an optional argument.
- Implementations MUST resolve each library namespace to the canonical package, at the pinned minimum version or above. Resolution and recording follow the pinning rules.
- A resolved catalog MUST NOT bind any other package to a canonical or reserved namespace. The diagnostic is
catalog.reserved_namespace.
Data-carrying packages. workweek holds calendar data, phone holds numbering-plan data, human holds locale wording, and money and url hold small symbol and parameter tables. That data ships inside the package. A data update is a new package version, and resolution pins it per run. A package that reads its data at evaluation time performs I/O and violates purity. Implementations MUST reject such a package.
Conformance and capabilities
Section titled “Conformance and capabilities”Each library package has a capability name of the form lib-<namespace>: lib-time, lib-store, lib-http, lib-data, lib-llm, lib-text, lib-html, lib-money, lib-rank, lib-workweek, lib-human, lib-url, lib-markup, lib-match, lib-phone.
Obligations divide by conformance level:
- Validation obligations bind every level. Implementations MUST resolve the canonical manifests, whatever capabilities they declare.
- Execution of a library action, trigger, or filter binds only an implementation that declares that package’s capability. A file that requires an undeclared capability is rejected with
capability.required.
An implementation conforms to the portable profile when it conforms at Level 2 or higher and declares every library capability. A Workfile whose connector and filter references stay within the library runs on every implementation of the portable profile, with identical validation results.
Canonical connectors
Section titled “Canonical connectors”The manifests are under conformance/library/connectors/. Below are the semantics that they cannot declare.
time holds the canonical schedule trigger. It declares no actions.
| Trigger | Kind | Configuration | Binds |
|---|---|---|---|
schedule |
schedule |
cron (string, required), timezone (string, required) |
tick — { scheduled_at, fired_at } |
timezone is an IANA zone identifier. It is required because an implicit default timezone is a silent divergence between deployments.
Cron grammar. cron holds five fields, separated by whitespace: minute, hour, day-of-month, month, day-of-week. Each field is *, an integer, a range a-b, a step */n or a-b/n, or a comma-separated list of those. Minute is 0–59, hour is 0–23, day-of-month is 1–31, month is 1–12, and day-of-week is 0–6 with 0 as Sunday. There are no name forms, no 7 for Sunday, no seconds field, and no @ keywords.
Matching. An instant matches when every field matches its local value in the declared zone. One exception is inherited from historical cron: when day-of-month and day-of-week are both restricted (neither is *), the instant matches when either matches.
Zone transitions. A local time that does not occur, because a zone transition skips it, fires once at the first instant after the gap. A local time that occurs twice fires on the first occurrence only.
scheduled_at is the matched instant, with the zone’s offset as its display zone. fired_at is the instant the implementation observed. Implementations MUST start exactly one run per matched instant while they are running. Behavior across implementation downtime is implementation-defined, and MUST be documented.
store is canonical cross-run state: single keys, no enumeration, and no query. The design rationale states why it is small.
| Action | Input | Output | Idempotent |
|---|---|---|---|
get |
key |
value: json, found: bool |
yes |
put |
key, value: json |
— | yes |
compare_and_set |
key, expected: json, value: json |
swapped: bool, value: json |
no |
increment |
key, by: int = 1 |
value: int |
no |
Atomicity. Implementations MUST apply each action atomically. Operations on one key are totally ordered, and each operation observes the effect of every operation ordered before it, across runs and across workflows that share the connection.
Null is absence. A stored value is never null. A put whose value is null or absent removes the key. get of an absent key yields found: false and value: null. A compare_and_set whose expected is null or absent matches only an absent key.
compare_and_set writes value when the stored value equals expected by the equality rules. Its output value holds the stored value after the operation: the new value when swapped is true, and the unchanged value otherwise.
increment adds by to the stored value, and treats an absent key as 0. A stored value that is not an int fails with wrong_type (fatal). A result outside the int range fails with overflow (fatal).
Bounds. A key is at most 512 code points. Implementations MUST accept a value up to 64 KiB in canonical serialization; a larger bound is implementation-defined, and a value over the bound fails with too_large (fatal).
Scope. Keys are scoped to the connection. The deployment decides which workflows share a connection, and therefore share keys. Concurrent read-modify-write against one key requires queue_by on that key, or compare_and_set.
http is the bridge to a system that the catalog does not cover. Its promises are the weakest in the library, because a manifest cannot know the remote.
| Action | Input | Output | Idempotent |
|---|---|---|---|
get |
url, headers, query, timeout = 30s |
status: int, headers, body: string |
yes |
post / put / delete |
url, headers, query, body: json, timeout = 30s |
status: int, headers, body: string |
no |
| Trigger | Kind | Binds |
|---|---|---|
webhook |
webhook |
request — { method, path, headers, query, body } |
get is promised idempotent because RFC 9110 §9.2.1 defines it as safe. put and delete carry no promise, although RFC 9110 defines them idempotent, because the manifest promises only what an unknown remote honors.
Request. url MUST be an absolute http or https URL; any other value fails with invalid_url (fatal). Entries of query are appended to the URL’s query, percent-encoded. Headers are merged in order: the connection’s headers parameter, then the step’s headers, with the later value winning per name. A body that is a string is sent as given. Any other body is sent in canonical JSON serialization, as the json filter defines it, with Content-Type: application/json unless a header supplies one.
Redirects. For get, implementations MUST follow redirects, up to 10; more fail with too_many_redirects (fatal). For post, put, and delete, implementations MUST NOT follow a redirect: a 3xx response binds as a result, because re-sending a body across a redirect diverges between clients.
Response. A 2xx or 3xx response binds the output. A 4xx or 5xx response is an action error whose code is the status. The manifest classifies 408 and 429 and 5xx retryable, and 4xx fatal. A response that ends without a status — a timeout or a lost connection — is classified unknown, so on_unknown applies. body is the response body decoded as UTF-8; an undecodable byte becomes U+FFFD. http moves text, not files: file transfer belongs to a storage connector.
Address policy. The url argument addresses the request, so the host-pattern rule cannot apply. Implementations MUST apply the deployment’s host policy to every http request, as they do for a self-hosted connector. By default that policy SHOULD exclude private, loopback, and link-local ranges.
The webhook trigger declares no correlate_on, so wait_for cannot target it.
data holds content operations on files. Expressions cannot read file contents, so each is an action. Every data action is deterministic, idempotent, and validated.
| Action | Input | Output |
|---|---|---|
checksum |
file, algorithm: enum[md5, sha256] = sha256 |
checksum: string, size: int, content_type: string |
parse_csv |
file, header: bool = true, delimiter = "," |
rows: list[json], count: int |
parse_json |
file |
data: json |
checksum yields lowercase hexadecimal, and size in bytes.
parse_csv reads RFC 4180: quoted fields, doubled quotes, CRLF or LF line ends, UTF-8 with an optional BOM. With header: true, each row is a map keyed by the header row; a repeated or empty header name fails with invalid_content (fatal). With header: false, each row is a list of strings. A row whose field count differs from the first row fails with invalid_content. Every field is a string; the author converts with core filters.
parse_json accepts one JSON document (RFC 8259). An input that is not valid JSON, holds trailing content, or repeats a member name fails with invalid_content. The rules match the core from_json filter.
There is no convert action.
llm holds portable model actions. The connection supplies the provider and the model: model is a required connection parameter, so a file never names one. Every llm action is nondeterministic; the manifest declares no deterministic promise, and recording is what makes a run replayable.
| Action | Input | Output |
|---|---|---|
categorize |
text, categories: list[string], instructions? |
category: string |
extract |
text or file, schema: map[string, string], instructions? |
the extracted object |
summarize |
text, instructions? |
summary: string |
generate |
prompt |
text: string |
transcribe |
file, language? |
text: string |
categorize. The result MUST be a member of categories; the action declares validated_output, and implementations MUST enforce the membership before binding. categories MUST hold at least one member. When categories is a list literal, implementations MUST treat the output category as enum[...] of exactly those members. A route over it is then exhaustively checkable.
extract. schema maps each field name to a type expression. A call MUST supply exactly one of text and file; implementations MUST reject a call that supplies neither or both, with catalog.invalid_argument. The step binds the extracted object directly: one field per schema entry, so candidate.summary reads a field named summary. Implementations MUST validate the result against schema before binding. When schema is a map literal, implementations MUST type the binding by it, and paths into it are statically checkable. When schema is computed, the binding is an open map[string, json], as an unrefined extension region is.
transcribe. language is a BCP 47 tag, as a hint. The result is the transcript text.
Errors. The manifest classifies by named code: rate_limited and provider_unavailable are retryable; invalid_request, content_filtered, and input_too_long are fatal. An interrupted request classifies unknown, as everywhere.
Provider-specific parameters do not enter the canonical surface. A provider-coupled connector can publish under its own namespace, and x- keys remain available to deployments.
wf stays reserved and undefined. A later library version defines it or retires the reservation.
Canonical filter packages
Section titled “Canonical filter packages”The manifests and fixtures are under conformance/library/packages/. Every filter satisfies the extension contract: pure, deterministic, total, no I/O, declared signature. Fixture files use the expression-fixture form.
Signatures below follow the core filter annex: a bracketed argument is optional, and T? marks a nullable result. Strings compare and measure by code point, with no normalization, as the value rules state.
Literal arguments. Several filters take an argument from a defined domain: a calendar name, a locale, a selector, a currency code. Where such an argument is a literal outside the domain, the file is invalid with filter.signature. Where it is computed, an out-of-domain value is an evaluation fault, fault.filter_argument.
Input domains. An input value outside a filter’s defined domain — a non-member of a scale, an amount or a number that does not parse — is an evaluation fault, fault.conversion, matching the core conversion filters.
| Filter | Signature and behavior |
|---|---|
slugify |
string → string — see below |
initials |
string → string — the first code point of each word, uppercased and concatenated; a word is a maximal run of non-whitespace |
truncate_words(n) |
string → string — the first n words, joined by one space; no marker is appended; n MUST be a positive integer |
slugify applies five steps in order: normalize to NFKD; remove combining marks; lowercase; replace each maximal run of characters outside [a-z0-9] with one -; trim leading and trailing -. A character with no ASCII decomposition is removed by step four, so the result can be empty.
| Filter | Signature and behavior |
|---|---|
select(css) |
string → string? — the text content of the first match in document order, whitespace-collapsed and trimmed as in core strip_html; null when nothing matches |
select_all(css) |
string → list[string] — the same text content, per match, in document order |
links |
string → list[object] — every a element that has an href, as { href: string, text: string }, in document order; href is as written, unresolved |
Input parses by the WHATWG HTML parsing algorithm, so malformed markup selects rather than faults.
Selector subset. The css argument is a closed subset of CSS: a type name, .class, #id, [attr], [attr="value"], compounds of those, and the descendant (space) and child (>) combinators. There is no grouping and no pseudo-class. A selector outside the subset follows the literal-argument rule above.
| Filter | Signature and behavior |
|---|---|
parse |
string → object — { amount: string, currency: string? }; see below |
to_currency(code) |
number → string — formats by ISO 4217; see below |
money formats and parses. Conversion needs rates, which is I/O, so it belongs to a connector.
parse accepts one amount with an optional currency marker: a leading or trailing symbol or ISO 4217 code from the package data. amount is the digits with grouping removed and . as the decimal separator; currency is the ISO code, or null when no marker is present. Separator rules: when both . and , appear, the last one is the decimal separator; a single separator followed by exactly three digits is grouping; followed by one or two digits, it is the decimal separator; anything else is an evaluation fault. An input that does not hold exactly one parsable amount is an evaluation fault: parse validates or faults, and never guesses.
to_currency(code) rounds to the currency’s minor-unit count (ISO 4217), half away from zero as in core round, groups thousands with ,, and uses . as the decimal separator. It prefixes the currency’s symbol where the package data defines one, and otherwise prefixes the ISO code and one space. Version 1.0.0 data defines symbols for USD ($), EUR (€), GBP (£), and JPY (¥).
rank orders enum values against a scale: a list[string] from lowest to highest.
| Filter | Signature and behavior |
|---|---|
in(scale) |
string → int — the zero-based position of the value in scale; a value that is not a member is an evaluation fault |
top(path, scale) |
list[object] → object? — the element whose value at path ranks highest; the first such element on a tie; null for an empty list; a non-member value at path is an evaluation fault |
The reverse mapping needs no filter: scale[i] indexes the list.
workweek
Section titled “workweek”Calendar-aware day arithmetic, over a scale that exact milliseconds cannot express. Each filter takes an optional calendar name; the default is mon_fri, which has no holidays. Version 1.0.0 ships mon_fri only; holiday calendars arrive as package data versions.
Every filter operates on local dates in the timestamp’s display zone, as format_time does. A workday is a day that the calendar does not exclude.
| Filter | Signature and behavior |
|---|---|
add_days(n, [calendar]) |
timestamp → timestamp — advances n workdays, preserving the local time of day; n MAY be negative; 0 yields the value unchanged |
is_workday([calendar]) |
timestamp → bool |
next_workday([calendar]) |
timestamp → timestamp — the value unchanged when its day is a workday, else the next workday at the same local time of day |
days_between(ts, [calendar]) |
timestamp → int — the count of workdays d with receiver < d ≤ ts, by local date; negated when ts is earlier |
A shifted result whose local time does not occur, because a zone transition skips it, moves forward by the width of the gap, as in the time connector.
Locale-directed formatting. locale is a required BCP 47 tag on every filter: wording is a locale, and an implicit default is a silent divergence, as the core exclusions state. Version 1.0.0 ships en; further locales arrive as package data versions.
| Filter | Signature and behavior |
|---|---|
ordinal(locale) |
int → string — 1st, 2nd, 11th, 21st |
int_comma(locale) |
int → string — grouped digits: 1,234,567 |
int_word(locale) |
number → string — 1.2 million; magnitudes below one million format as int_comma; one decimal place, half away from zero, with a trailing .0 dropped; the scale words are million, billion, trillion |
format_number(places, locale) |
number → string — fixed decimal places, grouped, half away from zero |
relative_to(ts, locale) |
timestamp → string — the receiver described relative to ts; see below |
relative_to selects the largest fitting unit from: year (365 days), month (30 days), week (7 days), day, hour, minute, with counts rounded down. A receiver before ts yields the “ago” form, and after it the “in” form. A difference under one minute yields the locale’s “just now” wording. The wording per locale is package data, verified by fixtures.
Core composes URLs (url_encode); url decomposes them. Inputs parse as URL references by the WHATWG URL standard. An input that cannot parse as a reference is an evaluation fault.
| Filter | Signature and behavior |
|---|---|
domain |
string → string? — the host, lowercased; null when the reference has none |
path |
string → string — the path component; the empty string when absent |
query(param) |
string → string? — the first value of param, percent-decoded; null when absent |
normalize |
string → string — absolute URLs only, else an evaluation fault; see below |
strip_tracking |
string → string — removes tracking query parameters; see below |
normalize lowercases the scheme and host, removes a default port (80 for http, 443 for https), makes an empty path /, percent-decodes unreserved characters, and uppercases remaining percent-escapes.
strip_tracking removes each query parameter whose name is in the package data: any utm_ prefix, and gclid, dclid, fbclid, msclkid, twclid, mc_cid, mc_eid in version 1.0.0. A query left empty is removed with its ?.
markup
Section titled “markup”markup moves Markdown to each destination format. The input dialect is CommonMark 0.31.2 exactly, with no extensions; an extension arrives only as a package version.
| Filter | Signature and behavior |
|---|---|
md_to_html |
string → string — CommonMark 0.31.2 rendering, with raw HTML escaped; see below |
md_to_slack |
string → string — Slack mrkdwn; see below |
md_strip |
string → string — the plain text; see below |
nl2br |
string → string — inserts <br> before each newline, and changes nothing else |
md_to_html produces the output that the CommonMark 0.31.2 reference tests define, with one deviation: a raw HTML block or inline is escaped as text, never passed through. A filter output that reaches an HTML mail body must not smuggle markup.
md_to_slack maps: strong to *bold*; emphasis to _italic_; a link to <url|text>; inline code and fenced code unchanged; a heading to a *bold* line; a bullet item to • ; an ordered item to n. ; a block quote line to > . Constructs mrkdwn cannot express render as their plain text.
md_strip yields the rendered plain text: markers removed, a link as its text, an image as its alt text, code as its content, and blocks joined by one newline.
nl2br does not escape. Escape first: {{ note | html_escape | markup.nl2br }}.
Approximate string matching, for deduplication and reconciliation. Distances count code points with no normalization, so canonically equivalent strings can measure unequal.
| Filter | Signature and behavior |
|---|---|
levenshtein(s) |
string → int — the edit distance to s: insertions, deletions, and substitutions, each at cost one |
similarity(s) |
string → float — 1 - levenshtein / max(length_a, length_b); 1.0 when both strings are empty |
Telephone number parsing and formatting. The numbering-plan metadata is package data, so a number’s validity is defined by the data version the run pinned. A region is an ISO 3166-1 alpha-2 code.
| Filter | Signature and behavior |
|---|---|
parse([region]) |
string → object — { e164: string, region: string }; an input that is not a valid number is an evaluation fault |
to_e164([region]) |
string → string — the E.164 form |
region |
string → string — the number’s region |
national_format([region]) |
string → string — the region’s national writing convention |
The region argument supplies the country context for an input without a country code. An input without a country code and without the argument is an evaluation fault, as is an input that is not valid for its region. In the manifest, the argument’s default is the empty string, which means that no region context is supplied.
Exclusions
Section titled “Exclusions”Exclusions the library states, beside the core exclusions:
data.convert. A file-format conversion matrix is open-ended, and every cell is a divergence. A conversion belongs to a vendor connector, or to a future package with a closed, fixture-pinned matrix.match.soundex. An English-only heuristic, whose result is wording rather than distance.- Format-directed time parsing.
parse_time(fmt, [locale])joins the library when a package ships fixtures for its locale, century, and impossible-date rules. - Heuristics. Language detection, sentiment, and address parsing diverge across implementations. They belong behind
llm.*, where nondeterminism is declared and recorded. - Actions in filter form. Translation, currency conversion, and geocoding are connectors. Signing is a connector’s executor concern.