Skip to main content
Reliant workflows use CEL (Common Expression Language) for dynamic values. CEL is a simple, safe expression language that allows you to reference data, perform calculations, and make decisions within your workflow YAML.

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

  1. Template Interpolation: Use {{expression}} to insert dynamic values into strings
  2. Pure Expressions: When an entire value is {{expression}}, the native type is preserved (not converted to string)
  3. Literal Strings: Text without {{}} is treated as a literal value
  4. 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:
Multiple expressions can appear in a single string:

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:
A {{ 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.
Raw-CEL fields (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.
Common patterns:

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:
Common CallLLM outputs: stop_reason values (a closed set — a new provider stop reason maps into one of these, never adds an eighth):
Common Run outputs (shell commands): 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’s on_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’s filter, 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.
Note: Previous iteration data is available via outputs.* in while conditions. For inject templates, use thread: mode: inherit to preserve context across iterations.

output.*

Used in save_message blocks to reference the current activity’s output.
Note: Token counts (input_tokens, output_tokens, etc.) and thinking are automatically extracted from activity output - no explicit configuration needed.

outputs.*

Used in loop while 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.
Returns a filter string like 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 a compute 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:
It prints the value and its CEL type, or the error with its phase (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 the response_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:
Access the structured response via response_data:
For more complex schemas, you can define arrays and nested objects:

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

Use has() 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, then iter.iteration increments before the while condition is checked to determine whether to continue.
Skipping the loop entirely: To conditionally skip a loop before it runs, use the node’s condition field:
Available namespaces in while:
  • outputs.* - Sub-workflow outputs defined in inline.outputs
  • iter.* - Loop iteration context (iter.iteration is 0-indexed)
  • inputs.* - Workflow inputs (useful for configurable iteration limits like inputs.max_turns)
Do-while behavior: The loop always runs at least once. After each iteration, 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

  1. Check node IDs: Ensure referenced node IDs match exactly (case-sensitive)
  2. Verify node completion: nodes.* only works after the node completes
  3. Use has() liberally: Wrap optional field access in has() checks
  4. Check types: Use type(x) to debug unexpected type errors
  5. Start simple: Build complex expressions incrementally

See Also