Reference syntax
The formal grammar for $. references, string interpolation, and when expressions — with the run-state vocabulary they evaluate against.
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.
| Status | Meaning |
|---|---|
pending | Not yet eligible; dependencies incomplete |
running | Started, not finished |
succeeded | Completed successfully |
failed | Completed unsuccessfully, after any retries |
skipped | Not 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 notnull. 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 <= 3The 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.