The shape of a document
A workflow is one YAML document. These are its top-level fields:
- name
- The workflow’s name. Batch lines refer to it as name@version.
- description
- Optional. One line for people reading the list of workflows.
- input.schema
- A JSON Schema for one item’s input. Every ${input.…} binding is checked against it.
- schemas
- Named JSON Schemas the rest of the document refers to as $ref: "#/schemas/<name>".
- nodes
- The steps, keyed by name. Order in the file does not matter.
- tools
- The registered endpoint every tool step calls. See Tools and external steps.
- assets
- Reference data shared by every item, and asset_bundle to read it from. See Assets.
Here is a complete one. It pulls the facts out of a support ticket, then drafts a reply and a log line from them:
name: ticket-triage
description: Pull the facts out of a support ticket, then draft a reply and a log line.
input:
schema:
type: object
required: [subject, body, plan]
properties:
subject: { type: string }
body: { type: string }
plan: { type: string, enum: [free, pro] }
schemas:
facts:
type: object
required: [product, issue]
properties:
product: { type: string }
issue: { type: string }
reply:
type: object
required: [text]
properties:
text: { type: string }
nodes:
extract:
target: { product: otium-small }
estimate: { tokens_in: 600, tokens_out: 80 }
body:
messages:
- role: system
content: "Extract the product and the issue. Reply as JSON."
- role: user
content: "Ticket ${run.custom_id}\nSubject: ${input.subject}\n\n${input.body}"
response_format:
type: json_schema
json_schema: { $ref: "#/schemas/facts" }
output:
validators:
- { type: json_schema, schema: { $ref: "#/schemas/facts" } }
on_invalid: { retry: 2 }
reply:
target: { product: otium-medium }
estimate: { tokens_in: 300, tokens_out: 250 }
body:
temperature: 0.3
max_tokens: 400
messages:
- role: system
content: "Draft a short, friendly support reply. Reply as JSON."
- role: user
content: "A ${input.plan} customer has this problem with ${nodes.extract.output.product}: ${nodes.extract.output.issue}"
output:
validators:
- { type: json_schema, schema: { $ref: "#/schemas/reply" } }
on_invalid: { retry: 1 }
log_line:
target: { product: otium-small }
estimate: { tokens_in: 200, tokens_out: 40 }
body:
messages:
- role: user
content: "Write one line for the support log from these facts: ${nodes.extract.output}"
Nodes
Each entry under nodes is one step. A step that runs a model has:
target.product: the size it runs on,otium-small,otium-mediumorotium-large. You choose a size, not a model; pricing says what each size is for and what it costs.body: an OpenAI chat-completions request.messagesis required; a message'scontentmay contain bindings, or be a list of conditional sections. Every other key you write, such astemperature,max_tokensorresponse_format, is passed through as written.estimate: optional,{ tokens_in, tokens_out }for one item. Only you know how long your inputs are and how much the model will write, so the duration and cost the compiler reports come from these numbers. Leave them out and it still compiles, but the figures are not worth much.outputandfallback: how the step's answer is checked, retried and escalated. See Validation and fallback.when: run the step only when a condition holds. See Conditions.afterandafter_settled: ordering you write by hand, below.
A step's type defaults to a model call. type: tool calls your HTTP
endpoint and type: external pauses the run while your system does the work; both
are on their own page. A model
step can be retried freely. A step that changes something on your side cannot, which is why
the type, not a comment, decides how it is retried.
If you set response_format to a JSON schema, it can point at a document schema
with $ref, and it must be the same schema the step's validator checks. Asking the
model for one shape and grading it on another is an error at publish.
Bindings
A binding is ${…} inside a message: a path to a value that is filled in when the
step runs. There are four places a path can start:
${input.<field>}: the item's input, as submitted.${nodes.<step>.output}, or a field below it such as${nodes.extract.output.product}: an earlier step's answer.${run.id}and${run.custom_id}: the run's id, and thecustom_idfrom your batch line.${assets.<name>}and${assets.<name>[<path>]}: shared reference data, see Assets.
That is the whole language. Paths use dots to go into nested fields. There is no logic, no
arithmetic, no loops and no function calls, so a binding can never hang or throw: it resolves,
or the document is rejected. The one form of indexing is a single […] on an asset, whose index is itself a path into the input or a step's output.
Bindings are checked when you publish, not when the batch runs. A path into the input must
exist in input.schema, and a path into a step's output must exist in the schema
that step's validator checks, so a typo such as ${nodes.extract.output.prodcut} fails with unknown_binding_path and a list of the fields that do exist. A malformed ${…} is an error too, never passed through as literal text.
What a step's output is, as a value:
- It is the model's message content, not the API response around it.
- If the content is JSON, it is decoded, so
${nodes.x.output.field}reads a field. A string field is inserted as it is. - An object or array, including the whole output, is inserted as compact JSON with its keys sorted. If a prompt needs the model's exact text, bind a string field instead.
- Content that is not JSON binds as one string.
The example's tests show each of these, including the re-encoded output:
workflow: document-ticket.yaml
cases:
- name: every binding resolves
custom_id: T-1042
input:
subject: "Export fails"
body: "The CSV export stops at 10,000 rows."
plan: pro
nodes:
extract:
{
output:
{ product: "Reports", issue: "CSV export stops at 10,000 rows" },
}
reply: { output: { text: "Thanks for flagging this." } }
log_line: { output: "Reports: CSV export truncated" }
expect:
status: succeeded
succeeded: [extract, reply, log_line]
prompts:
extract: { contains: ["Ticket T-1042", "Subject: Export fails"] }
reply:
{
contains:
["A pro customer has this problem with Reports: CSV export"],
}
log_line:
{
contains:
[
'{"issue":"CSV export stops at 10,000 rows","product":"Reports"}',
],
}
If you need to compute something between two steps, that is a tool step calling your code, not an expression in a binding.
Dependencies are derived, never declared
Add a step that reads another step's output and you have created an edge. There is no depends_on: the graph is whatever the bindings say it is, so it cannot drift from
the prompts. In the example, reply and log_line both read extract, so they run side by side once it finishes.
Edges come from every place a binding can appear: message text, conditional sections, a when condition, and the input of a tool or external step. A binding
that appears only inside a condition orders the steps but does not carry a skip across; the Conditions page explains why.
Ordering by hand: after and after_settled
The one edge you can write yourself is after: [step]: ordering with no data
flowing, for when a step has to finish before another starts. The usual reason is a write
on your side that a later step depends on without reading.
save: # your system writes the record (an external step)
type: external
input:
title: "${nodes.extract.output.title}"
describe:
after: [save] # describe reads nothing save produces
body:
messages:
- role: user
content: "Describe ${nodes.extract.output.title}"The compiler questions every one, because a pipeline copied from existing serial code is exactly where a wait the data doesn't need hides. Removing one is often the cheapest speedup available:
warn: fake_edge [describe]: nothing this node reads comes from "save", so
`after: save` is a wait the data does not require. Removing it lets this node start at
depth 1 instead of 2 — it would run alongside "save" rather than behind it. Keep it only
if "save" must genuinely *finish* first, which is the usual case for a step you run
yourself (a database write, say).after: [x] means "after x succeeded": if x is skipped
or fails, this step is skipped with it. When a step only has to wait for x to
have had its chance, write after_settled: [x] instead. It waits until x has succeeded, been skipped or failed, then runs either way.
Naming a step in after that a binding already depends on earns a redundant_after warning, and naming one step in both lists is an error, since the two
disagree about what a skip means.
What the compiler works out
otiumctl workflow validate workflow.yaml runs the same compiler as publishing, offline.
For the example above it prints:
3 nodes (0 external, 0 tool), critical path 2 deep: extract → reply
est. duration p50 12s / p95 47s, of which execution p95 47s (parks excluded: what the tier's budget is checked against)
est. cost $0.001194 per item, $11.94 per 10,000
(tokens as declared on each node; rates illustrative, not a quote)
2 affinity groups, 1 barriers, 0 cache groups- The critical path: the longest chain of steps that must run one after another, which sets how long an item takes.
- Duration, typical (p50) and slow (p95), from your token estimates. Time a run spends paused on an external step is reported apart, because it does not count against your window.
- Cost per item and per 10,000, from the same estimates. It is a guide to compare versions of a workflow, not a quote.
- How the work packs: affinity groups are chains of steps that can stay on one warm worker, barriers are where they cannot, and cache groups are steps that share a prompt prefix a model server can reuse.
Errors block a publish; warnings are advice. --strict fails on warnings too, so
the command drops into a pre-commit hook or a CI step. --plan prints the full
compiled plan as JSON and --mermaid prints the graph as a Mermaid flowchart. The
portal's editor shows the same results as you type, and POST /v1/workflows/validate returns them from the API.