Skip to content

CEL Expressions

Mantle uses CEL (Common Expression Language) for data flow and conditional logic in workflows. CEL is a small, fast, non-Turing-complete expression language designed by Google for security and policy evaluation. It is strongly typed, sandboxed, and evaluates in nanoseconds — making it ideal for workflow orchestration. See the CEL language spec for the full reference.

How Mantle Uses CEL

CEL expressions appear in two contexts inside a workflow YAML file:

  • Template interpolation in params values — wrapped in {{ }} delimiters, can be mixed with literal text.
  • Bare expressions in the if field — no {{ }} wrapper, evaluated as a boolean to decide whether a step runs.
steps:
  - name: notify
    action: http/request
    # Bare CEL — evaluated as boolean, no {{ }} needed
    if: "steps.check.output.status == 200"
    params:
      method: POST
      # Template CEL — embedded in a string with {{ }}
      url: "https://api.example.com/{{ steps.lookup.output.id }}/notify"
      body:
        message: "Hello {{ inputs.name }}, your request is ready."

Available Variables

Every CEL expression has access to four namespaces:

NamespaceExampleDescription
steps.<name>.outputsteps.fetch.output.json.titleOutput from a previously completed step.
inputs.<name>inputs.urlValues passed when the workflow is triggered.
env.<name>env.API_BASE_URLEnvironment variables (restricted to MANTLE_ENV_* prefix).
trigger.payloadtrigger.payload.repository.full_nameWebhook trigger data (server mode only).

Step outputs

Each connector populates output fields. For the HTTP connector, common fields are status, headers, body, and json (the parsed JSON body). Access them with dot or bracket notation:

# Dot notation
summary: "{{ steps.fetch.output.json.title }}"

# Bracket notation — required when step names contain hyphens
url: "{{ steps['get-user'].output.json.profile_url }}"

Workflow inputs

Inputs are declared at the top of the workflow file and passed at runtime:

inputs:
  name:
    type: string
  count:
    type: number

steps:
  - name: greet
    action: http/request
    params:
      url: "https://api.example.com/greet"
      body:
        greeting: "Hello {{ inputs.name }}"

Environment variables

Environment variables are available under env.*, but only those with the MANTLE_ENV_ prefix are exposed. The prefix is stripped in the expression:

# If MANTLE_ENV_API_BASE_URL is set:
url: "{{ env.API_BASE_URL }}/v1/resource"

Trigger data (webhooks)

In server mode, workflows triggered by webhooks can access the incoming payload:

repo: "{{ trigger.payload.repository.full_name }}"
action: "{{ trigger.payload.action }}"

Common Expressions

String operations

prompt: "Hello {{ inputs.name }}"
url: "https://api.example.com/{{ steps.lookup.output.id }}"
message: "Status: {{ steps.check.output.json.status }}"

Accessing nested data

# JSON response fields
summary: "{{ steps.fetch.output.json.title }}"

# Nested objects
city: "{{ steps.fetch.output.json.address.city }}"

# Array access
first_item: "{{ steps.list.output.json.items[0] }}"

Conditional execution (if field)

The if field uses bare CEL expressions — no {{ }} wrapper:

# Status code check
if: "steps.check.output.status == 200"

# Numeric comparison
if: "steps.analyze.output.json.score > 0.8"

# Check list length
if: "size(steps.fetch.output.json.items) > 0"

# Check field existence
if: "has(steps.prev.output.json.email)"

# Boolean logic
if: "inputs.verbose == true && steps.fetch.output.status == 200"

# Negation
if: "steps.fetch.output.body.contains('error') == false"

String functions

if: "steps.fetch.output.json.status.startsWith('2')"
if: "steps.data.output.json.email.contains('@company.com')"
if: "steps.input.output.json.name.endsWith('.pdf')"

Size checks

# String length
if: "size(steps.response.output.body) < 10000"

# List length
if: "size(steps.search.output.json.results) > 0"

Type conversions

# Convert number to string for concatenation
timeout: "{{ string(inputs.timeout_seconds) + 's' }}"

# Boolean checks
if: "steps.validate.output.json.valid == true"

Template vs Bare Expressions

This distinction is important and a common source of confusion:

ContextSyntaxExample
params values{{ expression }}"Hello {{ inputs.name }}"
if fieldbare expression"steps.check.output.status == 200"

Template expressions ({{ }}) can be mixed with literal text and are substituted into the string. You can have multiple templates in a single string:

url: "https://{{ env.API_HOST }}/users/{{ steps.lookup.output.id }}/profile"

Bare expressions in if must evaluate to a boolean. Do not wrap them in {{ }}:

# Correct
if: "steps.check.output.status == 200"

# Wrong — do not use {{ }} in if
if: "{{ steps.check.output.status == 200 }}"

Bracket vs Dot Notation

Bracket notation is required when step names contain hyphens, because CEL interprets - as subtraction:

# Correct — bracket notation for hyphenated names
if: "steps['get-user'].output.status == 200"

# Wrong — CEL reads this as steps.get minus user.output...
if: "steps.get-user.output.status == 200"

Dot notation works for step names without hyphens:

prompt: "{{ steps.summarize.output.json.summary }}"

Type Safety

CEL is a strongly typed language. Comparing values of different types produces an evaluation error at runtime rather than silent coercion.

Common type errors and fixes:

ExpressionProblemFix
inputs.count > "5"Comparing int to stringinputs.count > 5
steps.a.output.status + " OK"Adding int to stringstring(steps.a.output.status) + " OK"
steps.a.output.json.missing.fieldField may not existhas(steps.a.output.json.missing) ? steps.a.output.json.missing.field : "default"

Use has() to guard optional fields:

if: "has(steps.fetch.output.json.email) && steps.fetch.output.json.email.contains('@')"

The has() macro checks whether a field exists without triggering a type error. Use it when a previous step might not include a field in its output.

Data Flow Example

Consider this workflow:

inputs:
  url:
    type: string

steps:
  - name: fetch-data
    action: http/request
    params:
      method: GET
      url: "{{ inputs.url }}"

  - name: summarize
    action: ai/completion
    params:
      provider: openai
      model: gpt-4o
      prompt: "Summarize: {{ steps['fetch-data'].output.body }}"

The data flows like this:

  1. The caller provides url as an input when triggering the workflow.
  2. Step fetch-data reads inputs.url and makes an HTTP GET request.
  3. The HTTP connector returns output with fields like status, headers, body, and json.
  4. Step summarize reads steps['fetch-data'].output.body to build its prompt.
  5. The AI connector returns the completion result.

Each step can only reference outputs from steps that have completed before it runs. The engine detects these references automatically and treats them as implicit dependencies. When combined with explicit depends_on declarations, this enables parallel execution — see Execution Model.

List Macros

CEL provides built-in macros for working with lists. These operate on any list value — step output arrays, input arrays, or lists constructed inline.

.map(item, expr)

Transforms each element in a list by evaluating expr for every item.

steps:
  - name: extract-titles
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/batch"
      body:
        # Produce a list of title strings from a list of article objects
        titles: "{{ steps.fetch.output.json.articles.map(a, a.title) }}"

.filter(item, expr)

Returns a new list containing only the elements for which expr is true.

steps:
  - name: notify-failures
    action: http/request
    if: "size(steps.results.output.json.jobs.filter(j, j.status == 'failed')) > 0"
    params:
      method: POST
      url: "https://hooks.example.com/alert"
      body:
        failed_jobs: "{{ steps.results.output.json.jobs.filter(j, j.status == 'failed') }}"

.exists(item, expr)

Returns true if at least one element satisfies expr.

steps:
  - name: escalate
    action: http/request
    # Run this step only if any result has a critical severity
    if: "steps.scan.output.json.findings.exists(f, f.severity == 'critical')"
    params:
      method: POST
      url: "https://api.example.com/escalate"

.all(item, expr)

Returns true if every element satisfies expr.

steps:
  - name: mark-complete
    action: http/request
    # Only mark complete when every task is done
    if: "steps.fetch.output.json.tasks.all(t, t.done == true)"
    params:
      method: PATCH
      url: "https://api.example.com/projects/{{ inputs.project_id }}"
      body:
        status: "complete"

.exists_one(item, expr)

Returns true if exactly one element satisfies expr.

steps:
  - name: assign-owner
    action: http/request
    # Assign only when there is exactly one eligible owner
    if: "steps.fetch.output.json.members.exists_one(m, m.role == 'lead')"
    params:
      method: POST
      url: "https://api.example.com/assignments"

Chaining .filter() and .map()

Filter and map can be chained to first narrow a list and then reshape it.

steps:
  - name: summarize-errors
    action: ai/completion
    params:
      provider: openai
      model: gpt-4o
      prompt: >
        Summarize these error messages:
        {{ steps.logs.output.json.entries
             .filter(e, e.level == 'error')
             .map(e, e.message) }}

String Functions

Mantle registers the following string functions on top of CEL’s built-in string methods.

toLower()

Converts a string to lowercase.

steps:
  - name: normalize-tag
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/tags"
      body:
        tag: "{{ steps.input.output.json.label.toLower() }}"

toUpper()

Converts a string to uppercase.

steps:
  - name: set-env-key
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/config"
      body:
        key: "{{ inputs.variable_name.toUpper() }}"

trim()

Removes leading and trailing whitespace.

steps:
  - name: clean-input
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/search"
      body:
        query: "{{ inputs.search_term.trim() }}"

replace(old, new)

Replaces all occurrences of old with new.

steps:
  - name: slugify
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/pages"
      body:
        slug: "{{ inputs.title.toLower().replace(' ', '-') }}"

split(delimiter)

Splits a string into a list of strings at each occurrence of delimiter.

steps:
  - name: process-tags
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/items"
      body:
        # Convert "a,b,c" to ["a", "b", "c"]
        tags: "{{ inputs.tag_string.split(',') }}"

Type Coercion

These functions parse and convert values between types. They produce an evaluation error on invalid input — use default() to handle failure gracefully.

parseInt(string)

Parses a decimal string to an integer. Errors if the string is not a valid integer.

steps:
  - name: paginate
    action: http/request
    params:
      method: GET
      url: "https://api.example.com/results"
      body:
        page: "{{ parseInt(inputs.page_string) }}"

parseFloat(string)

Parses a string to a floating-point number. Errors if the string is not a valid float.

steps:
  - name: apply-threshold
    action: http/request
    if: "parseFloat(steps.score.output.body) > 0.75"
    params:
      method: POST
      url: "https://api.example.com/approve"

toString(value)

Converts any value to its string representation.

steps:
  - name: build-message
    action: http/request
    params:
      method: POST
      url: "https://hooks.example.com/notify"
      body:
        text: "Processed {{ toString(steps.count.output.json.total) }} records."

Object Construction

obj(key, value, ...)

Builds a map from alternating key-value arguments. Supports up to 5 key-value pairs (10 arguments) due to cel-go’s fixed-arity overload requirement — CEL does not support true variadic functions without macros. For maps with more than 5 pairs, use nested obj() calls or construct the value with jsonDecode.

steps:
  - name: create-record
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/records"
      body:
        record: "{{ obj('name', inputs.name, 'status', 'pending', 'source', 'mantle') }}"

obj() is particularly useful combined with .map() to reshape a list of objects into a different structure:

steps:
  - name: reformat-users
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/import"
      body:
        # Reshape each user to only include id and display_name
        users: >
          {{ steps.fetch.output.json.users.map(u,
               obj('id', u.id, 'display_name', u.first_name + ' ' + u.last_name)) }}

Utility Functions

default(value, fallback)

Returns value if it is non-null and does not produce an error; returns fallback otherwise. Use this to handle optional fields without a has() guard.

steps:
  - name: notify
    action: http/request
    params:
      method: POST
      url: "https://hooks.example.com/notify"
      body:
        # Use a default region when the field is absent from the response
        region: "{{ default(steps.fetch.output.json.region, 'us-east-1') }}"

flatten(list)

Flattens one level of nesting from a list of lists.

steps:
  - name: collect-all-items
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/process"
      body:
        # Each page returns a list; flatten to get a single list of items
        items: "{{ flatten(steps.paginate.output.json.pages.map(p, p.items)) }}"

JSON Functions

jsonEncode(value)

Serializes any value to a JSON string. Useful when a downstream API expects a JSON-encoded string field rather than a structured object.

steps:
  - name: store-metadata
    action: http/request
    params:
      method: PUT
      url: "https://api.example.com/records/{{ inputs.id }}"
      body:
        # The target API expects metadata as a JSON string, not an object
        metadata_json: "{{ jsonEncode(steps.fetch.output.json.metadata) }}"

jsonDecode(string)

Parses a JSON string to a structured value. Use this when a step returns a JSON-encoded string inside a field rather than a parsed object.

steps:
  - name: parse-config
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/apply"
      body:
        # steps.load.output.json.config_str is a JSON string — decode it first
        settings: "{{ jsonDecode(steps.load.output.json.config_str).settings }}"

Date/Time Functions

parseTimestamp(string)

Parses an ISO 8601 / RFC 3339 string to a CEL timestamp value. Named parseTimestamp rather than timestamp to avoid collision with CEL’s built-in timestamp() constructor.

steps:
  - name: check-expiry
    action: http/request
    if: "parseTimestamp(steps.fetch.output.json.expires_at) < parseTimestamp(\"2026-12-31T00:00:00Z\")"
    params:
      method: POST
      url: "https://api.example.com/renew"
      body:
        resource_id: "{{ inputs.resource_id }}"

formatTimestamp(timestamp, layout)

Formats a timestamp value to a string using a Go time layout. The reference time for Go layouts is Mon Jan 2 15:04:05 MST 2006.

steps:
  - name: create-report
    action: http/request
    params:
      method: POST
      url: "https://api.example.com/reports"
      body:
        # Format as "2006-01-02" (Go layout for YYYY-MM-DD)
        report_date: "{{ formatTimestamp(parseTimestamp(steps.fetch.output.json.created_at), '2006-01-02') }}"
        # Format with time for a human-readable label
        label: "Report for {{ formatTimestamp(parseTimestamp(steps.fetch.output.json.created_at), 'Jan 2, 2006') }}"

Limitations

  • env.* is restricted — only environment variables with the MANTLE_ENV_ prefix are available. This prevents accidental exposure of system secrets through CEL.
  • Secrets are NOT available in CEL — credentials are resolved as opaque handles at connector invocation time and are never exposed as raw values in expressions. See Secrets Management.
  • Resource limits — CEL evaluation is time-bounded and output-size-limited to prevent runaway expressions from affecting engine performance.
  • Not Turing-complete — CEL intentionally lacks loops and general recursion. It is an expression language, not a programming language.