Skip to content
Workflows

Conditions

Run a step only when the data calls for it, and include part of a prompt only when it applies.

Workflow docs

Run a step only when it applies

Give a step a when and it runs only if the condition holds. Otherwise it is skipped: it costs nothing, and the run carries on without it.

workflow.yaml (excerpt)
  refund:
    when:
      eq: { path: "${nodes.triage.output.category}", value: billing }

A condition is structured YAML, not a string to parse. Each one is a single operator, and the set is closed:

exists: "${…}"
The path has a value. False when it is missing.
is_not_empty: "${…}"
The path has a value that is not empty. False when it is missing.
is_empty: "${…}"
The path is empty or missing.
eq: { path: "${…}", value: … }
The value at path equals value.
ne: { path: "${…}", value: … }
The value at path is different from value.
all: [ conditions ]
Every condition holds. Stops at the first that does not.
any: [ conditions ]
At least one holds. Stops at the first that does.
not: { condition }
The condition does not hold.

eq and ne compare the value as written, so value: false matches a boolean false and value: 3 matches the number 3. Empty means missing, null, a blank string, or an empty list or object; false and 0 are values, not empty.

Conditions are checked when you publish. A path must exist in the schema it reads, and comparing a field against a value its schema can never hold, say a category outside its enum, fails with impossible_comparison, because that step could never run.

A worked example

A support ticket is triaged. Only billing tickets get a refund decision; bugs and refused refunds go to engineering; every ticket gets a reply, with optional lines that appear only when there is something to put in them.

conditions-support.yaml
name: support-routing
description: Triage a ticket, decide refunds only for billing, escalate bugs and refused refunds.

input:
  schema:
    type: object
    required: [subject, body]
    properties:
      subject: { type: string }
      body: { type: string }
      order_id: { type: string }

schemas:
  triage:
    type: object
    required: [category]
    properties:
      category: { type: string, enum: [billing, bug, question] }
      product: { type: string }
  refund:
    type: object
    required: [eligible, amount]
    properties:
      eligible: { type: boolean }
      amount: { type: string }
  note:
    type: object
    required: [text]
    properties:
      text: { type: string }

nodes:
  triage:
    target: { product: otium-small }
    estimate: { tokens_in: 500, tokens_out: 30 }
    body:
      messages:
        - role: user
          content: "Classify this ticket as billing, bug or question, and name the product if it says. Reply as JSON.\n\n${input.subject}\n${input.body}"
    output:
      validators:
        - { type: json_schema, schema: { $ref: "#/schemas/triage" } }
      on_invalid: { retry: 2 }

  # Runs only for billing tickets. triage never skips, so a plain eq is safe here.
  refund:
    target: { product: otium-small }
    estimate: { tokens_in: 600, tokens_out: 40 }
    when:
      eq: { path: "${nodes.triage.output.category}", value: billing }
    body:
      messages:
        - role: user
          content: "Is this customer owed a refund, and how much? Reply as JSON.\n\n${input.body}"
    output:
      validators:
        - { type: json_schema, schema: { $ref: "#/schemas/refund" } }
      on_invalid: { retry: 2 }

  # Reads refund's output in its prompt, so when refund is skipped, this is skipped too.
  refund_note:
    target: { product: otium-small }
    estimate: { tokens_in: 200, tokens_out: 60 }
    body:
      messages:
        - role: user
          content: "Write a one-line note confirming a refund of ${nodes.refund.output.amount}. Reply as JSON."
    output:
      validators:
        - { type: json_schema, schema: { $ref: "#/schemas/note" } }
      on_invalid: { retry: 1 }

  # Bugs, and refunds that were refused. refund only exists for billing tickets, so the
  # absence-tolerant `exists` is checked before the strict `eq` reads it.
  escalate:
    target: { product: otium-small }
    estimate: { tokens_in: 600, tokens_out: 60 }
    when:
      any:
        - eq: { path: "${nodes.triage.output.category}", value: bug }
        - all:
            - exists: "${nodes.refund.output.eligible}"
            - eq: { path: "${nodes.refund.output.eligible}", value: false }
    body:
      messages:
        - role: user
          content: "Summarise this ticket for the engineering queue. Reply as JSON.\n\n${input.body}"
    output:
      validators:
        - { type: json_schema, schema: { $ref: "#/schemas/note" } }
      on_invalid: { retry: 1 }

  # Always runs. Optional lines appear only when there is something to say.
  reply:
    target: { product: otium-medium }
    estimate: { tokens_in: 700, tokens_out: 250 }
    body:
      messages:
        - role: system
          content: "Draft a short support reply. Reply as JSON."
        - role: user
          sections:
            - text: "Subject: ${input.subject}\n"
            - when: { is_not_empty: "${input.order_id}" }
              text: "Order: ${input.order_id}\n"
            - when: { is_not_empty: "${nodes.triage.output.product}" }
              text: "Product: ${nodes.triage.output.product}\n"
            - text: "\n${input.body}"
    output:
      validators:
        - { type: json_schema, schema: { $ref: "#/schemas/note" } }
      on_invalid: { retry: 1 }

Its tests pin each branch, and say why each skipped step was skipped:

conditions-support.test.yaml
workflow: conditions-support.yaml

cases:
  - name: a billing ticket gets a refund decision
    input: { subject: "Charged twice", body: "I was billed twice this month." }
    nodes:
      triage: { output: { category: billing, product: "Pro plan" } }
      refund: { output: { eligible: true, amount: "$20" } }
      refund_note: { output: { text: "Refund of $20 confirmed." } }
      reply: { output: { text: "Sorry about that, the refund is on its way." } }
    expect:
      status: succeeded
      succeeded: [triage, refund, refund_note, reply]
      skipped: { escalate: guard_false }
      prompts:
        refund_note: { contains: ["a refund of $20"] }
        reply: { contains: ["Product: Pro plan"], not_contains: ["Order:"] }

  - name: a bug skips the refund branch and escalates
    input:
      {
        subject: "Crash on save",
        body: "The editor crashes when I save.",
        order_id: "A-1001",
      }
    nodes:
      triage: { output: { category: bug } }
      escalate: { output: { text: "Editor crashes on save." } }
      reply: { output: { text: "Thanks, engineering is looking at it." } }
    expect:
      status: succeeded
      succeeded: [triage, escalate, reply]
      skipped: { refund: guard_false, refund_note: upstream_skipped }
      prompts:
        reply: { contains: ["Order: A-1001"], not_contains: ["Product:"] }

  - name: a refused refund escalates
    input:
      { subject: "Refund please", body: "I want my money back from last year." }
    nodes:
      triage: { output: { category: billing } }
      refund: { output: { eligible: false, amount: "$0" } }
      refund_note: { output: { text: "Refund of $0 confirmed." } }
      escalate: { output: { text: "Refund refused; customer disputes." } }
      reply: { output: { text: "We have passed this to the team." } }
    expect:
      status: succeeded
      succeeded: [triage, refund, refund_note, escalate, reply]

When a value isn't there

A condition can read a step that was itself skipped, whose output therefore does not exist. What that means depends on the operator, and it is never left to guesswork:

  • exists, is_not_empty and is_empty treat a missing value as an answer: the first two are false, is_empty is true. Use them to ask whether an optional step produced anything.
  • eq and ne do not. Reaching one over a missing value skips the step with the reason upstream_skipped. There is no null and no "unknown": a missing value is never quietly equal or unequal to anything.
  • all and any stop as soon as the answer is known, left to right. So put an exists or is_not_empty first and the eq after it is only reached when there is something to compare.
workflow.yaml (excerpt)
  escalate:
    when:
      any:
        - eq: { path: "${nodes.triage.output.category}", value: bug }
        - all:
            - exists: "${nodes.refund.output.eligible}"   # false when refund was skipped
            - eq: { path: "${nodes.refund.output.eligible}", value: false }

The compiler enforces this. An eq or ne that reads a step which can skip, with no exists or is_not_empty before it, fails with unguarded_strict_predicate, so you decide at publish which of the two answers you meant. is_empty does not count as a guard: when it is true, the value is the one thing it proves is not there.

How skips travel

A step that is skipped or fails takes with it the steps that read its output, because their bindings have nothing to resolve to. They are skipped with the reason upstream_skipped or upstream_failed. In the example, a bug ticket skips refund, and refund_note, which puts the refund amount in its prompt, is skipped with it.

A step that only tests another in its when is different. The skip does not travel across; the condition decides what it means. That is how escalate still runs for a bug ticket although it reads refund, which never ran.

Hand-written ordering follows the same rule as reading: after carries a skip, after_settled does not. See Ordering by hand.

A skip a condition chose is the graph working as designed, not a failure. A run in which only conditions pruned steps ends succeeded with the outcome completed_with_skips. Each step in the run views carries its terminal_reason (guard_false for its own condition) and a disposition: pruned for a skip the graph chose, degraded for something that stopped a step running. Count failures by disposition, never by the number of skipped steps.

Optional lines in a prompt

Sometimes a whole step is not optional but one line of its prompt is. A message's content can be a list of sections instead, each with its own optional when. A section whose condition is false is left out, label and all, so an absent value never leaves a stray Order: with nothing after it. The reply step in the example does this:

workflow.yaml (excerpt)
        - role: user
          sections:
            - text: "Subject: ${input.subject}\n"
            - when: { is_not_empty: "${input.order_id}" }
              text: "Order: ${input.order_id}\n"
            - when: { is_not_empty: "${nodes.triage.output.product}" }
              text: "Product: ${nodes.triage.output.product}\n"
            - text: "\n${input.body}"

Section conditions use the same operators as when, checked as the prompt is built. Two rules follow from that:

  • Put a value that may be missing inside the section that tests it. Inside, a missing value just leaves the section out; anywhere else in the prompt it fails the step.
  • Sections are for optional fields: an input field that is not always sent, or an output field a step does not always fill. They do not rescue a step that was skipped. Reading a skipped step's output anywhere in a prompt, a section included, skips this step too.

Put the text every item shares first. A model server can reuse a prompt prefix that is identical across items, and a guarded section makes everything after it differ from item to item.

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.