Skip to content
Workflows

The workflow document

Every field of a workflow, how a step reads another step’s output, and how the order is worked out from that.

Workflow docs

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:

document-ticket.yaml
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-medium or otium-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. messages is required; a message's content may contain bindings, or be a list of conditional sections. Every other key you write, such as temperature, max_tokens or response_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.
  • output and fallback: 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.
  • after and after_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 the custom_id from 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:

document-ticket.test.yaml
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.

workflow.yaml (excerpt)
  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:

otiumctl workflow validate
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:

otiumctl workflow validate document-ticket.yaml
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.

Stop paying for speed you don’t need.

Otium is in closed alpha. Leave your email and we’ll send an invite when a spot opens. Then point your OpenAI-compatible client at our base URL and send your first batch.

No credit card, no spam — one email when your invite is ready.

Closed alpha — onboarding is gated while we calibrate. Already invited? Sign in.