Overview
In Reliant workflows, all string values in YAML are treated as potential CEL expressions. The template syntax{{expression}} is used to embed CEL expressions within strings.
Key Concepts
- Template Interpolation: Use
{{expression}}to insert dynamic values into strings - Pure Expressions: When an entire value is
{{expression}}, the native type is preserved (not converted to string) - Literal Strings: Text without
{{}}is treated as a literal value - Explicit Namespaces: All data access uses explicit namespaces (
inputs.*,nodes.*, etc.)
Quick Examples
Template Syntax
Basic Interpolation
Use double curly braces to embed expressions within strings:Type Preservation
When an entire YAML value is a single{{expression}}, the expression’s native type is preserved:
Literal Values
Text without{{}} is passed through as-is:
Template Rules
A{{ }} is read as CEL, not as text between braces, so these all work:
{{ that never closes, an empty {{ }}, and an expression that does not parse are
validation errors with the field path and the line and column inside the value. They are
never treated as literal text: the runtime fails the node rather than send the source of the
template to a model or a shell.
condition, while, a trigger filter) are not templates: write them without
{{ }}. A {{ inside a string literal there is data.
Available Namespaces
Reliant uses an explicit namespace model—all data access must use a namespace prefix. There is no implicit variable injection.inputs.*
Accesses workflow input parameters defined in the workflow schema.nodes.*
Accesses outputs from completed workflow nodes. Only available after the referenced node has completed.Optional Chaining (?.) for Conditional Nodes
When accessing nodes that may have been skipped (due to a condition: on the node), use optional chaining to safely handle the case where the node output doesn’t exist:
stop_reason values (a closed set — a new provider stop reason maps into one of these, never adds an eighth):
Loop outputs:
Loop outputs contain the last iteration’s sub-workflow outputs directly. Access them via
nodes.<loop_id>.<output_name> where <output_name> matches outputs declared in the loop’s inline workflow. nodes.<loop_id>._iterations is the number of iterations that ran; a parallel loop also has _results (each iteration’s outputs, by key), _completed and _failed.
workflow.*
Provides workflow metadata and execution context.workflow.path is never rendered as an empty string. In a run with no project
directory, reading it fails the node, the same way reading a missing key does.
Test for it with has(workflow.path) where such a run is expected.
Empty values in run commands
A run node fails before anything is sent to the machine if any plain value
reference in its command renders empty. Plain references are things like
{{workflow.path}}, {{inputs.dir}} and {{nodes.x.path}}. An empty value in
a shell command changes what the command does: rm -rf "{{inputs.dir}}/" with
an empty dir becomes rm -rf "/".
When a value is genuinely optional, write an expression that says what empty
means, for example {{has(inputs.flags) ? inputs.flags : ''}}.
failure.*
What failed, for the nodes of a workflow’son_failure handler and nowhere else: the namespace is undeclared in the main graph, so failure there is a compile error.
All five are always present, so a handler never fails reporting a failure. Reads of the main graph’s
nodes.* inside the handler must be guarded (has() or .?): the nodes after the failed one never ran.
trigger.*
The event that started the run, fixed at launch. Also the only namespace a trigger’sfilter, origin_filter, inputs and prompt can read. A chat started by a person has trigger.kind of chat.start.
trigger.origin has eight fields, all always present, so an expression never fails with “no such key”. An event nothing attributed reads caused_by_reliant: false, confidence: 'none', empty strings, same_workflow: false and lineage_depth: 0.
origin_filter is evaluated before filter, in the same language and over the same root. See Automations for what each confidence means and how skipped events are recorded.
iter.*
Provides loop iteration context. Only available inside loop constructs.outputs.* in while conditions. For inject templates, use thread: mode: inherit to preserve context across iterations.
output.*
Used insave_message blocks to reference the current activity’s output.
input_tokens, output_tokens, etc.) and thinking are automatically extracted from activity output - no explicit configuration needed.
outputs.*
Used in loopwhile conditions to reference the sub-workflow’s declared outputs.
Built-in Functions
Standard CEL Functions
String Functions
These are member functions: write
s.contains(x), not contains(s, x). The full string,
list, set, math and regex function set is in the function reference above.
Reliant Custom Functions
first(list)
Returns the first element of a list as an optional. Returns optional.none() if the list is empty.
Use .orValue(default) to unwrap with a default value, or .value() to unwrap (errors on empty).
last(list)
Returns the last element of a list as an optional. Returns optional.none() if the list is empty.
Use .orValue(default) to unwrap with a default value, or .value() to unwrap (errors on empty).
join(list, delimiter)
Joins list elements into a string with a delimiter.
parseJson(string)
Parses a JSON string into a CEL value (map or list).
toJson(value)
Converts a value to its JSON string representation.
coalesce(value1, value2, ...)
Returns the first non-null argument. Supports 2-4 arguments.
getOrDefault(map, key, default)
Safely accesses a map key with a fallback default value.
spawn(workflowRef, presets)
Generates a tool filter entry for spawning sub-agents. Used within tool_filter CEL expressions.
spawn:builtin://agent(general,researcher). If the presets list is empty, returns an empty string (effectively disabling spawn).
Standard Libraries
Every workflow expression, in the runtime, in validation and in trigger filters, has the CEL standard library plus these extensions:
Evaluation is cost-bounded: one expression may do at most 1,000,000 units of work (about one
per operation or per element a comprehension visits), and
parseJson, json.decode and toJson
are charged by the size of their text. An expression over the limit fails with
cost limit exceeded; narrow the data it loops over.
json.decode(string)
Parses JSON without failing: the value as an optional, or optional.none() when the text is empty
or is not exactly one JSON value. Use it on a node’s stdout, which is often “not JSON yet” when a
tool printed an error. parseJson is the strict form: bad input fails the step. Both keep JSON
integers as integers (225976313 stays 225976313, not 2.25976313e+08), and both refuse text
over 8 MiB.
Logic a script usually carries
These replace the data logic of a bash helper in a workflow, as acompute node’s outputs or in a
condition. Each is tested (internal/workflow/cel/helper_logic_test.go).
(In a markdown table the
| of a regex alternation is escaped as \|; write a plain | in YAML.)
Chain several of these in one compute node (see Nodes).
Testing an expression
Try an expression, a filter or an input mapping without running the workflow:syntax, compile, eval) and
position. A read the integration’s declared payload rules out is reported too, because a filter
that passes on a sample can still fail on a real event. Agents get the same thing as the
cel_eval tool.
Response Tool Data Access
Response tool data is available directly on ExecuteTools output via theresponse_data field. This field contains parsed response data keyed by tool name.
Response tools use JSON Schema to define structured outputs. A common pattern is choice/value:
response_data:
parseDuration(string)
Parses a Go duration string and returns seconds as a number.
Common Patterns
Conditional Logic
Use the ternary operator for conditional values:Default Values
Several patterns for handling missing or empty values:Checking Optional Fields
Usehas() to safely check for optional fields:
Working with Tool Calls
Common patterns for handling tool calls from LLM responses:Loop Iteration Patterns
Using iteration context in loops:String Interpolation
Embed multiple values in strings:Type Handling
CEL Type System
CEL is strongly typed. Values have specific types that affect how operators and functions work.Null Checks
Always check for null before accessing nested fields:Type Coercion
CEL performs limited automatic type coercion:Boolean Evaluation
Values are not implicitly converted to booleans. Use explicit comparisons:Edge Conditions
Edge conditions use CEL expressions without the{{}} wrapper:
Loop While Conditions
Loops use do-while semantics: the first iteration always executes, theniter.iteration increments before the while condition is checked to determine whether to continue.
condition field:
while:
outputs.*- Sub-workflow outputs defined ininline.outputsiter.*- Loop iteration context (iter.iterationis 0-indexed)inputs.*- Workflow inputs (useful for configurable iteration limits likeinputs.max_turns)
iter.iteration increments, then the while condition is evaluated.
Iteration counting: In the loop body, iter.iteration is 0-indexed (0, 1, 2…). In the while check, it reflects completed iterations (1 after first, 2 after second). Use iter.iteration < N to run exactly N iterations.
Common Mistakes
Missing Null Checks
Wrong Namespace
Using {{}} in Conditions
Empty String vs Null
Accessing Undefined Nodes
Debugging Tips
- Check node IDs: Ensure referenced node IDs match exactly (case-sensitive)
- Verify node completion:
nodes.*only works after the node completes - Use has() liberally: Wrap optional field access in
has()checks - Check types: Use
type(x)to debug unexpected type errors - Start simple: Build complex expressions incrementally
See Also
- Custom Workflows - Complete workflow YAML schema
- Types Reference - Node inputs and outputs
- CEL Language Specification - Official CEL documentation