CEL Expressions Reference

CEL Expressions Reference

CEL Expressions Reference

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

# Simple value interpolation
model: "{{inputs.model}}"

# Literal string (no interpolation)
role: user

# String with embedded expression
content: "Processing iteration {{iter.iteration + 1}} of {{iter.max}}"

# Conditional expression (ternary)
model: "{{inputs.model != '' ? inputs.model : 'claude-4-sonnet'}}"

# Accessing node outputs
tool_calls: "{{nodes.call_llm.tool_calls}}"

Template Syntax

Basic Interpolation

Use double curly braces to embed expressions within strings:

content: "Hello, {{inputs.username}}!"

Multiple expressions can appear in a single string:

content: "Running {{iter.iteration + 1}} of {{iter.max}} iterations"

Type Preservation

When an entire YAML value is a single {{expression}}, the expression’s native type is preserved:

# Returns array (native type preserved)
tool_calls: "{{nodes.call_llm.tool_calls}}"

# Returns integer (native type preserved)
max_turns: "{{inputs.max_turns}}"

# Returns string (interpolation converts to string)
message: "You have {{nodes.count.value}} items"

Literal Values

Text without {{}} is passed through as-is:

role: user
action: CallLLM
content: This is a literal string with no expressions

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.

inputs:
  model:
    type: model
    default: ""
  temperature:
    type: number
    default: 1.0
  mode:
    type: enum
    enum: ["manual", "agent", "plan"]
    default: "agent"

nodes:
  - id: call_llm
    action: CallLLM
    inputs:
      model: "{{inputs.model}}"
      temperature: "{{inputs.temperature}}"

Common patterns:

# Direct access
"{{inputs.model}}"

# With default fallback
"{{inputs.model != '' ? inputs.model : 'claude-4-sonnet'}}"

# Checking boolean inputs
"{{inputs.mode == 'agent'}}"

# Accessing grouped inputs
"{{inputs.Implementer.temperature}}"

nodes.*

Accesses outputs from completed workflow nodes. Only available after the referenced node has completed.

# Access a node's output field
"{{nodes.call_llm.response_text}}"

# Access nested fields
"{{nodes.call_llm.message.role}}"

# Access tool calls array
"{{nodes.call_llm.tool_calls}}"

# Check if a node completed with specific value
condition: nodes.run_tests.exit_code == 0

Common CallLLM outputs:

FieldTypeDescription
message.rolestringAlways "assistant"
message.textstringResponse text content
response_textstringSame as message.text
tool_callsarrayTool calls requested by model
input_tokensintInput tokens consumed
output_tokensintOutput tokens generated

Common Run outputs (shell commands):

FieldTypeDescription
exit_codeintCommand exit code (0 = success)
stdoutstringStandard output
stderrstringStandard error

Loop outputs:

FieldTypeDescription
iterationsintNumber of iterations completed
maxintMaximum iterations configured
succeededboolWhether loop completed successfully

thread.*

Provides access to the current thread context.

FieldTypeDescription
thread.idstringThread identifier (UUID)
thread.modestringThread mode (new, inherit, fork)
thread.forked_fromstringParent thread ID if forked
thread.messagesarrayAll messages in the thread
thread.last_messageobjectMost recent message
# Reference thread ID for activities
inputs:
  thread: "{{thread.id}}"

# Access last message content
content: "Previous message: {{thread.last_message.content}}"

# Check if thread has messages
condition: size(thread.messages) > 0

workflow.*

Provides workflow metadata and execution context.

FieldTypeDescription
workflow.idstringUnique workflow execution ID
workflow.namestringWorkflow definition name
workflow.pathstringWorking directory path
workflow.branchstringCurrent git branch
workflow.modestringExecution mode from inputs
# Create unique resource names
name: "worktree-{{workflow.id}}"

# Include workflow context in messages
content: "Workflow {{workflow.name}} completed"

# Access git branch
base_branch: "{{workflow.branch}}"

trigger.*

Provides data about the event that triggered the current node.

FieldTypeDescription
trigger.nodestringID of the node that triggered this one
trigger.idstringEvent ID
trigger.entity_idstringEntity ID (e.g., tool_call_id)
trigger.message.rolestringTrigger message role
trigger.message.contentstringTrigger message content
trigger.thread.idstringThread ID from trigger
trigger.attachmentsarrayAttachment IDs
# Access the user's original request
content: "Implement this: {{trigger.message.content}}"

# Reference in inject messages
thread:
  inject:
    role: user
    content: "Process: {{trigger.message.content}}"

iter.*

Provides loop iteration context. Only available inside loop constructs.

FieldTypeDescription
iter.iterationintCurrent iteration (0-indexed)
iter.maxintMaximum iterations
iter.previousobjectOutputs from previous iteration
# Display human-readable iteration number
content: "Attempt {{iter.iteration + 1}} of {{iter.max}}"

# Access previous iteration's output
content: |
  {{iter.iteration == 0 ? 
    trigger.message.content : 
    'Previous attempt failed: ' + iter.previous.stderr}}

# Use in loop until condition
until: outputs.exit_code == 0 || iter.iteration >= 5

output.*

Used in save_message blocks to reference the current activity’s output.

- id: call_llm
  action: CallLLM
  save_message:
    role: "{{output.message.role}}"
    content: "{{output.message.text}}"
    tool_calls: "{{output.tool_calls}}"
    input_tokens: "{{output.input_tokens}}"
    output_tokens: "{{output.output_tokens}}"

outputs.*

Used in loop until conditions to reference the sub-workflow’s declared outputs.

loop:
  max: 10
  until: outputs.exit_code == 0
  inline:
    outputs:
      exit_code: "{{nodes.verify.exit_code}}"

Built-in Functions

Standard CEL Functions

FunctionDescriptionExample
size(x)Length of list, map, or stringsize(nodes.call_llm.tool_calls) > 0
has(x.field)Check if field existshas(nodes.audit) ? nodes.audit.passed : true
type(x)Get type of valuetype(inputs.data) == "string"

String Functions

FunctionDescriptionExample
contains(s, substr)Check if string contains substringcontains(output.stderr, "error")
startsWith(s, prefix)Check if string starts with prefixstartsWith(inputs.model, "claude")
endsWith(s, suffix)Check if string ends with suffixendsWith(file, ".go")
matches(s, pattern)Regex matchmatches(output.stdout, "^OK")

Reliant Custom Functions

first(list)

Returns the first element of a list, or null if empty.

first_tool: "{{nodes.call_llm.tool_calls.first()}}"

last(list)

Returns the last element of a list, or null if empty.

last_message: "{{thread.messages.last()}}"

join(list, delimiter)

Joins list elements into a string with a delimiter.

# Join array elements
tools_list: "{{inputs.tools.join(',')}}"

parseJson(string)

Parses a JSON string into a CEL value (map or list).

data: "{{parseJson(nodes.fetch.body)}}"

toJson(value)

Converts a value to its JSON string representation.

# As global function
json_str: "{{toJson(nodes.call_llm.tool_calls)}}"

# As member function
json_str: "{{nodes.call_llm.tool_calls.toJson()}}"

coalesce(value1, value2, ...)

Returns the first non-null argument. Supports 2-4 arguments.

# Use default if input is null
model: "{{coalesce(inputs.model, 'claude-4-sonnet')}}"

# Chain multiple fallbacks
value: "{{coalesce(inputs.primary, inputs.secondary, 'default')}}"

getOrDefault(map, key, default)

Safely accesses a map key with a fallback default value.

name: "{{getOrDefault(inputs, 'name', 'unnamed')}}"

responseData(tool_results, tool_name)

Extracts parsed metadata from a response tool result.

audit_result: "{{responseData(nodes.execute_tools.tool_results, 'audit_result')}}"

now()

Returns the current UTC timestamp as an ISO 8601 string.

timestamp: "{{now()}}"
# Returns: "2025-01-15T10:30:00Z"

parseDuration(string)

Parses a Go duration string and returns seconds as a number.

timeout_seconds: "{{parseDuration('5m')}}"    # Returns: 300
total_seconds: "{{parseDuration('1h30m')}}"   # Returns: 5400

Common Patterns

Conditional Logic

Use the ternary operator for conditional values:

# Simple conditional
model: "{{inputs.model != '' ? inputs.model : 'claude-4-sonnet'}}"

# Nested conditional
prompt: "{{inputs.mode == 'plan' ? inputs.planning_prompt : inputs.system_prompt}}"

# Multi-level conditional
content: |
  {{iter.iteration == 0 ? trigger.message.content :
    iter.iteration == 1 ? 'First attempt failed: ' + iter.previous.stderr :
    'Multiple attempts failed: ' + iter.previous.stderr}}

Default Values

Several patterns for handling missing or empty values:

# Ternary with empty string check
model: "{{inputs.model != '' ? inputs.model : 'default-model'}}"

# Using coalesce (for null values)
value: "{{coalesce(inputs.optional, 'fallback')}}"

# Using getOrDefault (for map access)
setting: "{{getOrDefault(inputs, 'setting', 'default')}}"

# Combining approaches
model: "{{inputs.Custom.model != '' ? inputs.Custom.model : coalesce(inputs.model, 'default')}}"

Checking Optional Fields

Use has() to safely check for optional fields:

# Check if field exists before accessing
condition: "{{has(nodes.audit) ? nodes.audit.passed : true}}"

# Check nested optional fields
branch: "{{has(workflow.branch) ? workflow.branch : 'main'}}"

Working with Tool Calls

Common patterns for handling tool calls from LLM responses:

# Check if there are tool calls
condition: nodes.call_llm.tool_calls != null && size(nodes.call_llm.tool_calls) > 0

# In edge conditions
edges:
  - from: call_llm
    cases:
      - to: execute_tools
        condition: nodes.call_llm.tool_calls != null && size(nodes.call_llm.tool_calls) > 0
      - to: done
        label: "no_tools"

Loop Iteration Patterns

Using iteration context for progressive retry logic:

content: |
  {{iter.iteration == 0 ? 
    trigger.message.content : 
    'Previous attempt failed. Error:\n' + iter.previous.stderr + '\n\nTry again.'}}

String Interpolation

Embed multiple values in strings:

content: |
  ## Task Complete
  
  **Iterations:** {{nodes.loop.iterations}}
  **Status:** {{nodes.loop.succeeded ? 'Success' : 'Failed'}}
  **Exit Code:** {{nodes.verify.exit_code}}

Type Handling

CEL Type System

CEL is strongly typed. Values have specific types that affect how operators and functions work.

TypeDescriptionExamples
intInteger numbers0, 42, -1
doubleFloating point1.5, 0.0, -3.14
stringText"hello", ""
boolBooleantrue, false
listOrdered collection[1, 2, 3], []
mapKey-value pairs{"key": "value"}
nullNull valuenull

Null Checks

Always check for null before accessing nested fields:

# Safe null check pattern
condition: nodes.call_llm.tool_calls != null && size(nodes.call_llm.tool_calls) > 0

# Using has() for optional fields
value: "{{has(nodes.optional_node) ? nodes.optional_node.result : 'default'}}"

# Coalesce for null handling
model: "{{coalesce(inputs.model, 'default')}}"

Type Coercion

CEL performs limited automatic type coercion:

# Numbers in string interpolation are converted to strings
content: "Count: {{nodes.count.value}}"  # int -> string

# Explicit string conversion if needed
str_value: "{{string(nodes.count.value)}}"

Boolean Evaluation

Values are not implicitly converted to booleans. Use explicit comparisons:

# Correct: explicit comparison
condition: nodes.run.exit_code == 0

# Correct: explicit null check
condition: nodes.call_llm.tool_calls != null

# Wrong: implicit boolean conversion (will error)
# condition: nodes.run.exit_code  # Don't do this

Edge Conditions

Edge conditions use CEL expressions without the {{}} wrapper:

edges:
  - from: call_llm
    cases:
      # Condition is a raw CEL expression (no {{}} needed)
      - to: execute_tools
        condition: nodes.call_llm.tool_calls != null && size(nodes.call_llm.tool_calls) > 0
        label: "has_tools"
      
      # Combining multiple conditions
      - to: approval
        condition: nodes.call_llm.tool_calls != null && size(nodes.call_llm.tool_calls) > 0 && inputs.mode == 'manual'
        label: "require_approval"
      
      # Default case (no condition or always true)
      - to: done
        label: "complete"

Loop Until Conditions

The until clause in loops uses CEL to determine when to stop:

loop:
  max: 10
  until: outputs.exit_code == 0  # Raw CEL expression
  inline:
    outputs:
      exit_code: "{{nodes.verify.exit_code}}"

Available namespaces in until:

  • outputs.* - Sub-workflow outputs defined in inline.outputs
  • iter.* - Loop iteration context
  • thread.* - Thread context

Common Mistakes

Missing Null Checks

# Wrong: will error if tool_calls is null
condition: size(nodes.call_llm.tool_calls) > 0

# Correct: check for null first
condition: nodes.call_llm.tool_calls != null && size(nodes.call_llm.tool_calls) > 0

Wrong Namespace

# Wrong: 'message' is not a top-level namespace
content: "{{message.content}}"

# Correct: use trigger.message for trigger data
content: "{{trigger.message.content}}"

# Correct: use thread.last_message for thread messages
content: "{{thread.last_message.content}}"

Using {{}} in Conditions

# Wrong: don't use {{}} in edge conditions
condition: "{{nodes.run.exit_code == 0}}"

# Correct: raw CEL expression in conditions
condition: nodes.run.exit_code == 0

Empty String vs Null

# Empty string is NOT null - coalesce won't help
model: "{{coalesce(inputs.model, 'default')}}"  # Returns "" if inputs.model is ""

# Use ternary for empty string check
model: "{{inputs.model != '' ? inputs.model : 'default'}}"

Accessing Undefined Nodes

# Wrong: node hasn't completed yet or doesn't exist
content: "{{nodes.nonexistent.value}}"

# Correct: use has() for optional nodes
content: "{{has(nodes.optional) ? nodes.optional.value : 'N/A'}}"

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