Open Workflow GraphPre-release
Specification

Reference syntax

The formal grammar for $. references, string interpolation, and when expressions — with the run-state vocabulary they evaluate against.

View as markdownMachine-readable source for agents and scripted implementers

References are how a task reads a parameter or an upstream task's output. Conditions are how a task decides whether to run at all. Both are given here as complete grammars, because a syntax without a grammar cannot be parsed reliably by a person writing a regex or by a model generating a document.

Both grammars are in ISO/IEC 14977 EBNF. Terminals are quoted; { } is zero-or-more; [ ] is optional.

Shared lexical rules

identifier  = ( letter | digit ) { letter | digit | "_" | "-" } ;
letter      = "A" … "Z" | "a" … "z" ;
digit       = "0" … "9" ;
ws          = { " " | "\t" } ;

Identifiers match the id patterns used elsewhere in the specification, so any task, parameter, or asset id is a legal path segment without escaping.

References

A reference addresses a value in the run state. There are two syntactic positions, and the distinction matters because they return different things.

value-reference = "$." path ;
interpolation   = "${" path "}" ;

path            = params-path | task-path | workflow-path | task-self-path ;

params-path     = "params." identifier ;
task-path       = "tasks." identifier "." task-field ;
task-field      = "outputs." identifier
                | "status" ;
workflow-path   = "workflow." ( "id" | "version" ) ;
task-self-path  = "task." ( "id" | "attempt" ) ;

Value references

When a JSON string consists entirely of a value reference, the whole string is replaced by the referenced value, preserving its type.

{
  "inputs": {
    "input_key":   "$.tasks.ingest.outputs.output_key",
    "add_captions": "$.params.add_captions"
  }
}

add_captions resolves to a boolean, not the string "true". This is why the whole-string form exists: it is the only way to pass a non-string parameter through.

Interpolation

When a reference appears inside a larger string, it uses the brace form and is substituted as text.

{
  "outputs": {
    "output_key": "jobs/${workflow.id}/${task.id}/output.mp4"
  }
}

Interpolated values are stringified. A boolean becomes true, a number its decimal form, and an array or object is a validation error — you cannot interpolate a structure into a path.

Escaping

A literal dollar sign is written $$.

{ "inputs": { "label": "Cost: $$40 per hour" } }

$$ is the only escape. There is no backslash escaping, because JSON already owns the backslash.

What is deliberately absent

Stated so you do not go looking:

  • No array indexing or filters. No [0], no [*], no [?(...)]. If you need an element, name an output that holds it.
  • No arithmetic. References read; they do not compute.
  • No nested references. $.params.${...} is invalid.
  • No cross-document references. A reference resolves within one document. Cross-document linkage is a relationship, not a reference.
  • No scatter/gather construct. Fan-out over a collection is modelled as explicit tasks, which keeps per-item status, cost, and disclosure individually addressable — see Implementation considerations.

This is not JSONPath. The syntax borrows JSONPath's $. sigil and nothing else. Do not reach for a JSONPath library to evaluate it — the grammar above is the whole language, and it is small enough to parse directly.

Run state vocabulary

Conditions frequently test a task's status, so the status values are part of the specification rather than left to an implementation.

StatusMeaning
pendingNot yet eligible; dependencies incomplete
runningStarted, not finished
succeededCompleted successfully
failedCompleted unsuccessfully, after any retries
skippedNot run, because its when evaluated false or a dependency was skipped

$.tasks.<id>.status resolves to one of exactly these five strings.

Note the interaction: a task depending on a skipped task is itself skipped, not pending. Skip propagates. That is what stops an optional branch from blocking the graph.

when expressions

when decides whether a task runs. If the expression evaluates false, the task is skipped.

expression   = or-expr ;
or-expr      = and-expr { ws "||" ws and-expr } ;
and-expr     = unary   { ws "&&" ws unary } ;
unary        = [ "!" ws ] primary ;
primary      = "(" ws expression ws ")"
             | function
             | comparison
             | operand ;

comparison   = operand ws comp-op ws operand ;
comp-op      = "==" | "!=" | "<=" | ">=" | "<" | ">" ;

function     = "exists" "(" ws value-reference ws ")" ;

operand      = value-reference | literal ;
literal      = string | number | boolean | "null" ;
string       = "'" { character - "'" } "'" ;
number       = [ "-" ] digit { digit } [ "." digit { digit } ] ;
boolean      = "true" | "false" ;

Precedence, tightest first: ! → comparison operators → &&||. Parentheses override.

Semantics

  • Type discipline. Comparisons are between like types. Comparing a string to a number is a validation error, not a coercion. == and != work on all types; the ordering operators (<, <=, >, >=) apply only to numbers.
  • Strings are single-quoted. Double quotes would need escaping inside JSON.
  • exists() returns true when the reference resolves to a value that is present and not null. This is the intended answer to the optional-upstream problem: a task that consumes an output which may or may not have been produced.
  • An unresolvable reference is a validation error, not a false. A typo should fail loudly at validation, not quietly skip a task at run time.

Examples

$.params.add_captions == true
$.tasks.qc.status == 'succeeded' && $.params.territory != 'JP'
exists($.tasks.caption.outputs.captions_key)
!($.tasks.grade.status == 'failed') && $.params.rounds <= 3

The third example is the one worth internalising. A task that consumes an optional upstream output should gate on exists() rather than assuming the output is there — otherwise the document claims a dependency it cannot guarantee.

A worked pair

A conditional caption step, and a publish step that tolerates its absence:

{
  "params": {
    "add_captions": { "type": "boolean", "required": false, "default": false }
  },
  "tasks": [
    {
      "id": "caption",
      "type": "work",
      "executor": { "type": "agent" },
      "ai_role": "generative",
      "performed_by": "caption_agent",
      "depends_on": ["edit"],
      "when": "$.params.add_captions == true",
      "used": ["master_mp4"],
      "produced": ["captions_vtt"],
      "failure_mode": "SKIP_DEPENDENTS"
    },
    {
      "id": "publish",
      "type": "delivery",
      "executor": { "type": "saas_api" },
      "performed_by": "youtube_api",
      "depends_on": ["edit", "caption"],
      "when": "$.tasks.edit.status == 'succeeded'",
      "used": ["master_mp4"],
      "inputs": {
        "captions_key": "$.tasks.caption.outputs.captions_key",
        "include_captions": "exists($.tasks.caption.outputs.captions_key)"
      },
      "produced": ["publish_record"],
      "failure_mode": "HALT"
    }
  ]
}

publish depends on caption so ordering is defined, but gates on edit succeeding rather than on caption succeeding — and passes an exists() result so the executor knows whether captions are actually available. The alternative, listing captions_vtt in used[] unconditionally, would assert a lineage edge to an asset that may never exist.

Implementer's note

The grammars above are small on purpose. A complete recursive-descent parser for both is a few hundred lines, and that is the intended cost — a language small enough to implement correctly beats a large one implemented three incompatible ways.

If you are generating documents rather than parsing them, the practical rules are:

  • Whole-value reference for typed values; ${...} only inside a larger string.
  • Single-quoted strings in conditions.
  • Gate on exists() whenever an upstream output is conditional.
  • Keep conditions to one or two clauses. A condition that needs parentheses is usually a task that should be split.