Authoring Guide
Status: Draft · Companion to the Workfile specification · Non-normative
This guide is not part of the specification. Nothing here adds to or overrides the spec; where the two disagree, the spec wins. What this document does is answer the two questions the spec deliberately does not: where should this piece of logic live? and how do I express the workflow shape I already have in my head?
Where logic lives
Section titled “Where logic lives”A Workfile is the efficient form for composed actions, even when the task runs once. An agent that composes actions through its own tool calls moves every intermediate result through its context, as tokens. A Workfile moves the same data through the engine, and a model contributes only the file. The validator checks the file before any side effect occurs. The engine holds the catalog, the connections, and the credentials in one standard environment. An agent uses that environment by submitting a file instead of calling each tool itself. One case needs no file: a question that a model answers by reading the data itself.
Choose where logic lives by what it needs to observe and produce:
| You need… | Reach for… |
|---|---|
| A pure computation over values already in scope | An expression, in a wf.value step or inline |
| Prose or markup the workflow owns — an email body, an alert message | A local template, inline in a wf.render step or in files beside the Workfile |
| A pure computation the core filters can’t express | An extension filter package, with a declared contract and fixtures |
| A small piece of state that must survive across runs | The store namespace |
| A loop where the next call depends on the last result | A self-transitioning state in the states profile |
| Anything that touches the outside world | A connector from the catalog |
| A system the catalog does not cover, including your own application | A private connector you write |
| A sub-task whose steps can’t be specified in advance | wf.agent |
Two consequences of this layout are worth internalizing:
External systems need connector coverage. If the catalog lacks an integration, provide a private connector or use the canonical http connector where its contract is sufficient. Plan that coverage before writing the workflow. The connector guide covers the private path, where a manifest and a function reach your own application in its own process.
Start with expressions and existing actions. Deriving a field per element is each. Filtering on a field is where(path, op, value); filtering on a computed test is keep(expr). Pulling repeated matches out of text is extract_all. A lookup table can use a declared map[string, T] value with default. A counter is store.increment. Package a more involved pure computation as a filter when the available filters cannot express it.
wf.run is reserved and unavailable in this Standard revision. Its language registry is empty, so every use is invalid with document.reference. The reserved embedded-code companion records a non-normative design, not an available authoring option.
Translating imperative habits
Section titled “Translating imperative habits”Workflows usually arrive as imperative pseudocode. Several common shapes have a Workfile rendering that is not the literal translation — and the non-literal version is the one that validates, replays, and scales. These are the recurring ones.
“Poll on a schedule, fetch what’s new, save the cursor”
Section titled ““Poll on a schedule, fetch what’s new, save the cursor””TRIGGER: schedule (hourly)items = source.fetch_since(last_check)FOR item IN items: ...set(last_check, now)Don’t build this. The schedule-plus-cursor loop is what a poll trigger is: the trigger owns the cursor, the implementation manages it, and your file receives one event per item. The Workfile version has no cursor, no store, and no batch loop:
on: reviews.received: {} # kind: poll — cursor lives with the trigger connection: acme-yelp as: review
steps: - log: { sheet.append_row: { table: Reviews, row: { rating: "{{ review.rating }}" } } } - alert: when: "{{ review.rating <= 2 }}" slack.post: { channel: "#reputation", text: "{{ review.url }}" }Two cursors, and which one you need. A poll trigger’s cursor advances after it has retained a disposition for each item before that position. It tracks ingestion, including queued or batched events, rather than the destination work a run has completed.
A workflow that walks a source and writes as it goes needs the other cursor: the position that the destination reached. The two separate exactly when a run stops part way. A failure at page 63 leaves sixty-two pages written, and a cursor that advanced on reading would resume past them.
sync.watermark is that position, because sync.record is what writes it. A run that stops part way leaves the watermark on the last record it wrote:
steps: - mark: { sync.watermark: { scope: articles } } - ingest: wf.for_each: cms.list_posts: { since: "{{ mark.at }}" } as: post max: unbounded retain: none steps: [ ... ]A connector can also hold this cursor privately, and the connector guide shows that form. Use a store key for a position that is neither: a page number in a source that has no notion of modification time, or a marker that a vendor returns and that means nothing outside its own API.
“Loop over services”
Section titled ““Loop over services””FOR platform IN [x, linkedin, facebook]: platform.publish(adapt(post, platform))There is no dynamic connector dispatch — connector.action names are static so that every call is validated against a manifest before the run starts. The loop unrolls into named wf.parallel branches, which is also where per-platform differences (length limits, hashtag style) stop pretending to be uniform:
- publish: wf.parallel: x: [{ post: { x.publish: { text: "{{ content.body | text.truncate(280) }}" } } }] linkedin: [{ post: { linkedin.publish: { text: "{{ content.body }}" } } }] facebook: [{ post: { facebook.publish: { text: "{{ content.body }}" } } }] on_branch_fail: continue“For each X where …”
Section titled ““For each X where …””FOR attendee IN event.attendees WHERE attendee.domain != our_domain: crm.log_activity(contact, type="meeting", ...)Filter at the source of the wf.for_each, not inside its body — a when repeated on every step of the body is the loop telling you the list was wrong. A test on a field as it stands is where; a computed test — here the domain has to be derived from the email — is keep:
- followups: wf.for_each: "{{ event.attendees | keep((current.email | split('@') | last) != inputs.our_domain) }}" as: attendee max: 50 steps: - contact: { crm.find_or_create: { email: "{{ attendee.email }}" } } - activity: { crm.log_activity: { contact: "{{ contact.id }}", type: meeting } }Prefer where when it can say the thing (where('status', '==', 'open')): it states the field, comparison, and value directly.
“Wait, re-check, maybe stop” (drip sequences)
Section titled ““Wait, re-check, maybe stop” (drip sequences)”WAIT 1.hour; IF cart.completed: STOPemail(...)WAIT 23.hours; IF cart.completed: STOP...The poll-and-recheck stanza is a workaround for not having events. Workfile has events: wf.wait_for with a timeout inverts the logic — the completion interrupts the wait, and the timeout is what advances the drip:
- first_window: wf.wait_for: commerce.checkout_completed match: { checkout_id: "{{ cart.id }}" } timeout: 1h - done: when: "{{ first_window.status == 'received' }}" wf.stop: "customer completed checkout" - reminder_1: mail.send: { to: "{{ cart.customer_email }}", template: cart_reminder_1 }For longer sequences, the states profile expresses the same thing as states with timeout: { after: ..., goto: ... } and an on: subscription to commerce.checkout_completed that transitions to a final state — one place to see the whole lifecycle.
“A second trigger for the callback”
Section titled ““A second trigger for the callback””TRIGGER: deal moves to "Proposal" → send envelopeTRIGGER 2: esign webhook (envelope.completed) → move stageThe second trigger exists only because the pseudocode’s runtime can’t suspend. A Workfile run can, and correlation routes the callback to the suspended run — so the deal context is still in scope when the answer arrives, and the two halves can’t drift apart:
- envelope: { esign.send: { file: "{{ pdf.file }}", signer: "{{ deal.contact_email }}" } } - signed: wf.wait_for: esign.envelope_completed match: { envelope_id: "{{ envelope.id }}" } timeout: 14d - advance: when: "{{ signed.status == 'received' }}" crm.move_stage: { deal: "{{ deal.id }}", stage: "Contract Signed" } - expired: when: "{{ signed.status == 'timeout' }}" crm.log: { deal: "{{ deal.id }}", note: "proposal expired" }“TRIGGER: A OR B”
Section titled ““TRIGGER: A OR B””TRIGGER: new item in RSS feed OR new row in "Content Calendar" sheetpost = {title, body, link}Two sources of one logical event are a single file with two triggers, and into is where the payload shapes reconcile: each trigger projects into the same shared binding, so the steps see one shape and never branch on trigger.name:
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 }}" }
steps: - publish: wf.parallel: x: [{ posted: { x.publish: { text: "{{ post.title }} {{ post.link }}" } } }] linkedin: [{ posted: { linkedin.publish: { text: "{{ post.body }}" } } }] on_branch_fail: continueIf the triggers call for different behavior rather than different field names, that is two Workfiles sharing their common tail via wf.call — into reconciles shapes, not logic.
“If it exists, update; else create”
Section titled ““If it exists, update; else create””Predicate actions (contact_exists) split the read from the branch. Do the lookup once, bind the result, and branch on a declared result field with wf.choose. Here the connector omits its optional id when no contact exists, so existing.id != null is the test. A connector can instead declare a found flag; follow its contract. A successful action result is an object, so testing the whole binding against null does not test whether the contact exists:
- existing: { crm.find_contact: { email: "{{ lead.email }}" } } - upsert: wf.choose: - when: "{{ existing.id != null }}" then: [{ updated: { crm.update_contact: { id: "{{ existing.id }}", fields: "{{ lead }}" } } }] else: - created: { crm.create_contact: { fields: "{{ lead }}" } } - staged: { crm.add_to_pipeline: { contact: "{{ created.id }}", stage: "New Lead" } }“Round-robin assignment”
Section titled ““Round-robin assignment””Round-robin needs a counter that survives across runs. store.increment is the primitive built for exactly this:
- cursor: { store.increment: { key: "sales_round_robin" } } - owner: wf.value: "{{ inputs.sales_team[cursor.value % (inputs.sales_team | length)] }}" - assign: { crm.assign_owner: { contact: "{{ created.id }}", owner: "{{ owner }}" } }If the CRM offers round-robin assignment natively, prefer the connector — the catalog owning operational detail beats the file re-implementing it.
“Fetch every page”
Section titled ““Fetch every page””rows = []; cursor = ""DO: page = api.list(cursor) rows += page.items cursor = page.nextWHILE cursor != nullUsually, don’t write this loop at all. A cursor walk is transport, and the catalog absorbs transport: a well-made list action returns the complete result for its filters, and a poll trigger owns its own cursor. Meeting this loop in a file usually means the manifest is unfinished.
Where the result is too large to hold at once, an action source lets wf.for_each take the list action itself. An implementation can read its result incrementally, and retain: none keeps completed iteration values out of run state:
- walk: wf.for_each: helpdesk.list_tickets: { since: "{{ mark.at }}" } as: ticket max: unbounded retain: none steps: [ ... ]The literal translation is also unwritable on purpose: wf.repeat iterations share no bindings, so no iteration can read the cursor the last one fetched. The format has exactly one loop that carries a value forward — a states file whose state transitions to itself, with the carried values crossing in into and declared in accepts:
initial: start
states: start: # the initial state cannot declare accepts, goto: fetch # so a seed state supplies the first carry into: { cursor: "", rows: [] }
fetch: accepts: cursor: { type: "string", required: true } rows: { type: "list[json]", required: true } steps: - page: { helpdesk.list_tickets: { cursor: "{{ cursor }}" } } route: "{{ 'more' if page.next != null else 'done' }}" cases: more: { goto: fetch, into: { cursor: "{{ page.next }}", rows: "{{ [rows, page.items] | flatten }}" } } done: { goto: emit, into: { rows: "{{ [rows, page.items] | flatten }}" } } else: { goto: emit, into: { rows: "{{ [rows, page.items] | flatten }}" } }
emit: accepts: { rows: { type: "list[json]", required: true } } final: true
outputs: tickets: "{{ rows }}"Three habits of the shape:
- A transition routes on values, so a boolean decision routes on a computed label —
'more' if … else 'done'— never ontrueas a case name. - Appending is a list literal plus
flatten:[rows, page.items] | flatten. There is no+on lists. - This self-transition has no iteration bound in the document.
wf.for_eachoptionally declaresmaxand defaults tounbounded;wf.repeatrequires a finitemax. Deployment resource limits still apply. That is one more reason the catalog should own pagination.
Keep the loop behind a wf.call, so the sequential caller stays sequential and binds the loop file’s result. Callees can call other Workfiles: the complete graph must be acyclic, and implementations must support at least eight documents of depth, counting the initial Workfile. A called file cannot declare on.
“On failure, try the next provider”
Section titled ““On failure, try the next provider””FOR provider IN [primary, backup]: IF provider.send(msg): BREAKThere is no break, and one iteration cannot see another. For a known, small set of alternatives, unroll into a fallback chain: set on_fail: continue on each attempt that permits fallback, and guard each later attempt on the earlier binding. A reference to a failed step resolves to null, so the guard is a null test:
- via_primary: sms_a.send: { to: "{{ lead.phone }}", text: "{{ notice }}" } on_fail: continue - via_backup: when: "{{ via_primary == null }}" sms_b.send: { to: "{{ lead.phone }}", text: "{{ notice }}" }The chain is sequential steps, not a wf.choose: a wf.choose selects its arm before anything runs, and cannot observe an attempt’s failure. Each alternative is also its own static action — there is no dynamic connector dispatch (“Loop over services”, above).
This fallback handles definite failure. An unresolved write result follows on_unknown: the default abort terminates the run even under on_fail: continue, on_iteration_fail: continue, or on_branch_fail: continue. If the workflow intentionally permits fallback after ambiguity, put on_unknown: fail on that action. It passes flow.action_ambiguous to ordinary failure policy; the original send may still have happened.
When the alternatives are data rather than steps — a list of mirror URLs, a ladder of page sizes for one action — the chain becomes the loop-with-carry of the previous entry, with an index as the carry (into: { i: "{{ i + 1 }}" }) and alternatives[i] as the argument.
“Halve the batch and try again”
Section titled ““Halve the batch and try again””size = 100TRY bulk.send(records, size)ON overflow: size = size / 2; RETRYThe retry modifier re-issues the step with the same evaluated arguments, and the manifest classifies which failures that can fix. A retry that changes its arguments is a new call, and it is the loop-with-carry again: the state carries what changes, and the route reads what happened. Two choices shape it:
- Adapt on a declared result field (
sent.truncated) where the API offers one. A failed step withon_fail: continuebindsnull, so a route after a failure cannot see the error code — only that the attempt failed. - Carry a position on a ladder of literals rather than computing the next argument:
[100, 25, 5][i]shows its bound, and its exhaustion is a route arm. Halving with arithmetic hides the bound and needs an int conversion.
attempt: accepts: { i: { type: "int", required: true } } steps: - sent: bulk.send: { records: "{{ inputs.records }}", page_size: "{{ [100, 25, 5][i] }}" } on_fail: continue route: "{{ 'done' if sent != null else ('smaller' if i < 2 else 'exhausted') }}" cases: done: { goto: confirmed } smaller: { goto: attempt, into: { i: "{{ i + 1 }}" } } exhausted: { goto: alert_ops } else: { goto: alert_ops }“email(user, template=…, data=…)”
Section titled ““email(user, template=…, data=…)””A template has two possible homes, and the pseudocode doesn’t say which. When the external system owns the content — an ESP campaign, a DocuSign envelope, a CMS layout — pass data to the template-holding connector and let it render. When the workflow owns the content — transactional mail, alerts, chat messages — the template is local files beside the Workfile, rendered with wf.render:
templates/order_confirmation/ subject.txt body.txt body.html<p>Thanks, {{ order.customer_name }}!</p><table>{{#each order.items as item}} <tr><td>{{ item.name }}</td><td>{{ item.qty }}</td></tr>{{/each}}</table> - confirmation: wf.render: templates/order_confirmation with: { order: "{{ order }}" } - confirmed: mail.send: to: "{{ order.customer_email }}" subject: "{{ confirmation.subject }}" text: "{{ confirmation.body_txt }}" html: "{{ confirmation.body_html }}"A short body goes inline in the step instead — wf.render: { text: "…" }, or wf.render: { parts: { ... } } for several — and the checks are identical, so nothing forces a second file.
There is no schema file. The variables are inferred from the template body, and with is checked against them in both directions before any run starts. An unsupplied variable is permitted only when every reference is protected by default, as in {{ discount | default('') }}. An {{#if discount != null }} block alone does not make omission valid.
HTML parts escape interpolations except values carrying the safe_html designation, such as the result of markup.md_to_html, which are emitted unchanged in HTML context. Ordinary strings cannot assert that designation, and there is no unregistered escaping bypass.
Per-audience templates are chosen statically — a wf.route over the audience with one wf.render per case, never a computed path. Over a validated enum, that turns “we added a department with no welcome email” into a compile error.
Idioms
Section titled “Idioms”Small patterns that come up often enough to name, but not often enough to deserve syntax.
Ordered enums (the rank idiom)
Section titled “Ordered enums (the rank idiom)”Enum values are strings, whose lexical order need not match a business priority. Assign each choice a numeric rank, compare numbers, and index a list to map back:
- urgency_rank: wf.value: "{{ 1 if classify.urgency == 'low' else (3 if classify.urgency == 'high' else 2) }}" - tier_rank: wf.value: "{{ 3 if account.tier == 'enterprise' else 2 }}" - priority: wf.value: "{{ ['low', 'normal', 'high'][([urgency_rank, tier_rank] | max) - 1] | required }}"The ranks are explicit, and required makes a missing list element a fault. A list index is an integer; map keys and map indexes are strings. Map literals infer closed object shapes, which also prohibit computed string indexing, so quoting numeric keys would not produce an equivalent lookup. If a rank scale recurs across files, the canonical rank package holds the reusable form.
Guarded groups (the one-armed wf.choose)
Section titled “Guarded groups (the one-armed wf.choose)”when guards a single step, including an explicit wf.value. Only the compact value form accepts no modifiers; use a conditional expression such as {{ E if c else null }} there. To guard a block of steps on one condition, use a wf.choose with one arm and an empty else:
- big_deal: wf.choose: - when: "{{ classify.category == 'sales' and enrich.deal_size > 50000 }}" then: - exec_ping: { slack.post: { channel: "#big-deals" } } - task: { crm.create_task: { title: "White-glove follow-up" } } else: []The spec defines a single arm with omitted or empty else as a guarded block. Writing the empty else makes the no-op path visible. References permitted by static validation into the unexecuted arm resolve to null.
The same shape answers “the rest of this iteration only when …” inside a wf.for_each. There is no step that ends one iteration early; the steps that depend on the condition go under the arm. When the arm is not selected, its steps are not_run. skipped means a step’s own when was false.
Refusing (the wf.fail step)
Section titled “Refusing (the wf.fail step)”A workflow that must refuse — a tripped safety threshold, a precondition a probe found false — ends the run with wf.fail and a code:
- threshold_guard: when: "{{ orphan_pct > inputs.max_archive_pct }}" wf.fail: "refused to archive {{ orphans | length }} of {{ local.count }} mappings ({{ orphan_pct | round(1) }}%)" code: threshold_exceededThe run ends failed, the record holds the code and the reason, and a caller’s wf.call step fails with threshold_exceeded. Calling an action whose only purpose is to raise expresses the same decision through argument validation, retry policy, and error classification, and a reader has to open the manifest to learn that the step always fails. wf.stop is the other half: it ends the run early and successfully, for “nothing to do”.
Partial progress
Section titled “Partial progress”A walk that applied 3,100 of 3,500 records and then met an upstream limit can report that progress. There is no partial outcome; choose whether to fail or stop successfully. Resuming the same run requires Durable execution, while a later run needs a saved cursor or watermark:
- walk: wf.for_each: { cms.list_posts: { since: "{{ mark.at }}" } } as: post max: unbounded retain: failures on_iteration_fail: continue steps: - put: { warehouse.put_post: { body: "{{ post }}" } } - report: when: "{{ walk.failed > 0 }}" wf.fail: "applied {{ walk.succeeded }} of {{ walk.count }}; {{ walk.failed }} failed, first: {{ walk.failures[0].error.code }}" code: partialon_iteration_fail: continue keeps the construct’s summary readable after ordinary iteration failures; unresolved ambiguity with the default on_unknown: abort still terminates the run. A construct that fails under fail_fast has no successful binding, and a permitted reference resolves to null. wf.fail with a code says “failed, and here is how far it got”; wf.stop with a reason says “stopped early” — pick the one an operator’s filter should see. A later run can continue from progress saved in the connector’s own cursor or in sync.watermark; the outcome itself saves no cursor.
Null before a comparison
Section titled “Null before a comparison”== and != treat null as just another value, but an ordered comparison against null is an evaluation fault: it fails the step rather than quietly picking a branch. When a value can legitimately be absent, decide its stand-in at the point of use:
- notify_exec: when: "{{ (enrich.crm.lookup.deal_size | default(0)) > 50000 }}" slack.post: { channel: "#big-deals" }default supplies the stand-in; has is the alternative when absence should take its own branch instead of a default value. Member access and indexing on null yield null, so enrich.crm.lookup.deal_size reads as null when the crm branch failed, and one default at the end of the path is enough.
Money stays in minor units
Section titled “Money stays in minor units”Floats carry money through a file safely — a value that arrives as 19.99 leaves as 19.99, by the shortest round-trip rule — but float arithmetic on money can introduce rounding error. The format’s exact numeric type is int, and the money package converts at the boundaries. So the idiom is: convert in, compute as integer minor units, format out:
- total_cents: wf.value: "{{ order.items | each(current.price | money.to_minor('USD')) | sum }}" - alert: when: "{{ total_cents > 500000 }}" slack.post: channel: "#finance" text: "Large order: {{ total_cents | money.from_minor('USD') | money.to_currency('USD') }}"to_minor reads decimal digits and never rounds — an amount with too many fractional digits for the currency is a fault, and an intentional rounding is an explicit round first. It also knows each currency’s minor-unit count, so JPY (0 digits) and BHD (3) need no author knowledge.
Two boundaries to respect. Compare and accumulate in minor units; format with money.to_currency or human.format_number only at the point of display. And keep derivation out of the file: tax, proration, and allocation are the billing system’s arithmetic, so call the connector that owns them rather than re-deriving amounts in expressions.
What becomes an input
Section titled “What becomes an input”inputs is the file’s contract with whoever starts a run, so the question is not “which values might vary” but “which values must the caller decide.” A workable rule: a literal becomes an input when it would change with the deployment — another team adopting the file, the same file reached by wf.call for a different purpose — and stays a literal when it would only change with the workflow. Editing the file is the right cost when the logic itself changed.
Habits that keep the block friendly:
- Start with zero inputs, and promote literals as real divergence appears. An input nobody varies is a literal with ceremony.
- Prefer
defaultoverrequired: true. A caller who can start a run with{}learns the contract incrementally; reserverequiredfor values with no safe stand-in. - Give every input a
description, and anexampleslist where the type alone doesn’t teach the shape. Tools can render run forms from them, and every example must satisfy the declaration. - A structured input deserves a named schema.
list[json]with a comment describing the real shape documents the contract without enforcing it — the schema moves that check to before the run starts.
Rendering a list into text
Section titled “Rendering a list into text”Reports and notifications often need “one line per item.” each plus text.format plus join is the pattern; no template engine is involved:
- summary_lines: wf.value: "{{ deltas | each([current.name, current.pct] | text.format('{}: {}% WoW')) | join('\n') }}"When the output is a rich document rather than lines of text, don’t build markup in expressions: render a local template when the workflow owns the content, or hand the data to a template-holding connector (docs.create_from_template) when the external system does.
Parenthesize where the parse isn’t obvious
Section titled “Parenthesize where the parse isn’t obvious”Filters bind tighter than comparison, so {{ a | length > 0 }} parses as {{ (a | length) > 0 }}. That’s the useful reading, but it isn’t the one a reader guesses. Write the parentheses. Chained comparison is a syntax error (a < b < c), so there is nothing to guess there — but mixed filter-and-arithmetic chains benefit from the same habit, and a formatter should insert them rather than rely on precedence.
wf.route dispatches on behavior, not on values
Section titled “wf.route dispatches on behavior, not on values”When branches differ only in a value — a channel name, a queue, a label — a conditional expression in a wf.value step can express the choice:
- channel: wf.value: "{{ '#wins' if event.type == 'sale' else ('#support' if event.type == 'ticket' else '#general') }}"For a larger lookup supplied as an input of type map[string, string], use inputs.channels[event.type] | default('#general'). The declared map type permits computed string indexing; an inline map literal has a closed object shape and does not. Keep wf.route for branches that differ in what they do. Over a validated enum it earns its verbosity, because the validator then proves no case was forgotten.
queue_by orders runs; it doesn’t share anything between them
Section titled “queue_by orders runs; it doesn’t share anything between them”A queued run inherits no binding from the run before it. What it sees is whatever effect the earlier run left in the external system. If you wanted a value carried forward, you wanted store.
Ordering is also durable in the awkward direction: a run holds its key until it reaches a terminal outcome, including while suspended. A file with wf.wait_for: ... timeout: 14d and a queue_by can hold that queue for the fourteen-day wait and any work that follows. That is usually not what the author pictured.
batch and queue_by can be combined. With batching, queue_by sees the list-valued payload and is evaluated once per closed batch; equal-key batches are ordered by closure. Use batch.partition_by to keep different per-event keys in separate batches. The prohibited pairing is batch with overlap.
Declare the public result of a file you intend to wf.call
Section titled “Declare the public result of a file you intend to wf.call”A wf.call step binds the callee’s successful workflow result. Normal completion evaluates outputs, or returns an empty map when they are absent. Early success through wf.stop instead returns that step’s result, which defaults to an empty map. The inferred public contract joins the normal-completion shape with every declared stop-result shape, so fields supplied by only some paths are nullable. Callee step ids are never exposed directly.
inputs: account: type: "string" required: true description: The billing account identifier.
outputs: refund_id: "{{ issued.id }}" amount: "{{ issued.amount }}" - refund: wf.call: workflows/refunds/process.workfile with: { account: "{{ enrich.billing.lookup.id }}" } - confirm: slack.post: { text: "Refunded {{ refund.amount }} ({{ refund.refund_id }})" }Treat outputs and every wf.stop.result as the callee’s public surface. Renaming an internal step while updating its references is a private change; renaming a result field is a breaking one. A field outside the joined result contract is a validation error when referenced by a caller.
Failed iterations are ordinary data
Section titled “Failed iterations are ordinary data”With on_iteration_fail: continue on wf.for_each or on_branch_fail: continue on wf.parallel, each retained scope carries a reserved error binding: null when the scope has no failure, and { step, code, message, details } otherwise. details is connector-supplied json, or null when unavailable. For a wf.for_each named attachments with retain: all (the default), reporting on partial failure needs no special construct:
- failed: wf.value: "{{ attachments.items | keep(current.error != null) }}" - report: when: "{{ (failed | length) > 0 }}" slack.post: channel: "#ops" text: "{{ failed | each([current.error.step, current.error.message] | text.format('{}: {}')) | join('\n') }}"With retain: failures, use attachments.failures directly; items does not exist. With retain: none, only the counts remain. A wf.parallel result instead contains named branch scopes, so its errors are reached through paths such as publish.x.error.
Under the default fail_fast, the first unhandled scope failure fails the construct. Unresolved write ambiguity with on_unknown: abort terminates the run even when continuation is selected.