CEL Expressions
Write dynamic conditions for Gravity Rail workflows using CEL (Common Expression Language). Use it to control access to tasks, route conversations, and trigger automations.
Quick Start
CEL expressions evaluate to true or false. Here are common patterns:
# Check if member has a label
"vip" in member.labels
# Check form data
member.data.profile.age >= 18
# Check time
datetime.hour >= 9 && datetime.hour < 17
# Check chat channel
chat.channel == "phone-sms"
Operators
| Type | Operators | Example |
|---|---|---|
| Comparison | == != < > <= >= | member.data.score >= 80 |
| Logic | && (AND) || (OR) ! (NOT) | "vip" in member.labels && is_business_hours |
| Containment | in | "premium" in member.labels |
| Math | + - * / | member.data.quantity * 10 |
Available Variables
member
Information about the current member.
Available in: All contexts where a member is associated — event rules, ability conditions, router tasks, and edge routing conditions. All fields in the table below are available on every surface.
| Field | Type | Description |
|---|---|---|
member.id | number | Member's internal numeric ID |
member.name | string | Member's name |
member.first_name | string | Member's first name (falls back to first token of name when unset) |
member.last_name | string | Member's last name (empty string when unset) |
member.email | string | Member's email |
member.phone | string | Member's phone number |
member.account_uuid | string | UUID of the Member's owning account |
member.external_id | string | External system identifier (empty string when unset) |
member.date_of_birth | string | Member's date of birth (YYYY-MM-DD) |
member.age | number | Member's age in whole years (-1 when date of birth is not set) |
member.labels | list of strings | Label slugs assigned to member (e.g., ["vip", "verified"]) |
member.label_uuids | list | UUIDs of labels assigned to member |
member.data.{type}.{field} | varies | Data from Data Types (singular and collection) |
member.collections.{type}.count | number | Total records for a collection Data Type |
member.collections.{type}.latest.data.{field} | varies | Latest record from collection Data Types (by creation time) |
member.collections.{type}.latest.created_at | string | Timestamp of latest record |
member.collections.{type}.latest.external_id | string | External ID of latest record |
member.collections.{type}.recent | list | Up to 50 records, newest first (supports exists, filter, map, size) |
Examples:
# Check for a label
"verified" in member.labels
# Check multiple labels
"vip" in member.labels || "enterprise" in member.labels
# Element equality with a comprehension is also valid
member.labels.exists(l, l == "vip")
# DON'T: member.labels.exists(l, l.slug == "vip")
# Each element is already the slug string — accessing .slug on it
# raises a runtime error and the rule is silently skipped. The save-time
# validator now rejects this pattern with a clear error.
# Check age or date of birth
member.age >= 18
member.date_of_birth < "2008-01-01"
# Check singular form data
member.data.patient_info.age >= 18
member.data.contact.preferred_language == "spanish"
# Collection: count and latest
member.collections.patient_visits.count > 0
member.collections.patient_visits.latest.data.diagnosis == "Allergies"
# Collection: search recent records (up to 50)
member.collections.lab_results.recent.exists(r, r.data.status == "critical")
# Collection: filter returns matching records as a list (size() to count)
member.collections.appointment_tracking.recent.filter(e, e.data.appointment_type_id == 376657 && e.data.status == "Scheduled").size() > 0
# Same pattern scoped to the current Assignment only
assignment.collections.call_log.recent.filter(e, e.data.call_category == "patient_care_management").size() > 0
member.experiments (A/B testing)
Map of Experiment slug → assigned Group slug. Populated on runtime surfaces (Actions, automatic task edges) when a CEL expression reads a slug and lazy assignment runs.
Available in: Event rule conditions, automatic task edge CEL, and other assignment-capable runtime surfaces. Not available for side-effect-free assignment on filters or journey goal definition CEL.
| Access pattern | Type | Description |
|---|---|---|
member.experiments["my-experiment"] | string | Assigned Group slug |
member.experiments.my_experiment | string | Dot syntax (hyphens → underscores) |
"my-experiment" in member.experiments | bool | Whether the Member has an assignment |
Lazy assignment: First read of a slug on a runtime surface may assign the Member (Experiment must be running, eligibility CEL must pass), record exposure, then evaluate the branch.
Unassigned Members: Missing key → equality checks evaluate false. Use "slug" in member.experiments when you need to detect assignment explicitly.
member.experiments["opening-message-test"] == "friendly"
member.experiments["opening-message-test"] == "control"
"opening-message-test" in member.experiments
See Experiments (A/B Testing) for eligibility filtering and reporting.
chat
Information about the current conversation.
Available in: Message event rules, router tasks, ability conditions.
| Field | Type | Description |
|---|---|---|
chat.id | number | Chat ID |
chat.uuid | string | Chat UUID |
chat.channel | string | Channel (see values below) |
chat.chat_type | string | "assignment", "manager", "developer", "supervisor" |
chat.paused | bool | Whether the chat is paused |
chat.needs_response | bool | Whether the chat needs a response |
chat.is_test | bool | Whether this is a test chat |
chat.title | string | Chat title (if set) |
Channel values: "phone-sms", "phone-voice", "email", "web-chat", "web-voice", "cli", "frame", "discord", "slack", "direct-message"
Examples:
# Different behavior for SMS vs web
chat.channel == "phone-sms"
# Voice channels
chat.channel == "phone-voice" || chat.channel == "web-voice"
# Skip test chats
!chat.is_test
# Only respond when needs response
chat.needs_response
phone_number
The workspace phone number the current chat arrived on. Only populated for chats with a linked workspace phone number — typically phone-voice and phone-sms channels. Absent for web-chat and other channels.
Available in: Message event rules, router tasks, and ability conditions on chats that came in via a workspace phone number.
| Field | Type | Description |
|---|---|---|
phone_number.number | string | E.164 phone number (e.g. "+15551234567") |
phone_number.name | string | Human-readable name configured on the workspace phone number |
phone_number.brand_name | string | Brand name configured on the workspace phone number (used for compliance and caller ID) |
Examples:
# Route based on which workspace number was dialed
phone_number.number == "+15551234567"
# Brand-specific behavior when a workspace has multiple lines
phone_number.brand_name == "Acme Health"
task
Information about the current task.
Available in: Ability conditions, router task edges.
| Field | Type | Description |
|---|---|---|
task.id | number | Task ID |
task.uuid | string | Task UUID |
task.name | string | Task name |
assignment
Information about the current assignment (a member's progress through a workflow).
Available in: Ability conditions, task guards, edge conditions, and event rules on assignment / task events.
| Field | Type | Description |
|---|---|---|
assignment.id | number | Assignment ID |
assignment.uuid | string | Assignment UUID |
assignment.collections.{type}.count | number | Records of this collection Form linked to this Assignment only |
assignment.collections.{type}.latest.data.{field} | varies | Latest current-Assignment record field |
assignment.collections.{type}.latest.created_at | string | Timestamp of latest current-Assignment record |
assignment.collections.{type}.latest.external_id | string | External ID of latest current-Assignment record |
assignment.collections.{type}.recent | list | Up to 50 current-Assignment records (newest first; supports exists, filter, map, size) |
assignment.collections.* vs member.collections.*
These two namespaces have the same shape but different scope:
assignment.collections.<form>.*sees only records linked to the current Assignment through the agent's data-access action. Use this for per-encounter checks like "did the agent collect a reason on this call?".member.collections.<form>.*sees the Member's lifetime records — every record ever created for that Form, across all Assignments and all time. Use this for historical checks like "has the Member ever completed intake?".
Pick deliberately. A common bug is writing member.collections.call_log.latest.data.reason for a current-call check, then having the rule fire on a leftover record from a previous call.
| Intent | CEL |
|---|---|
| Block until the agent collects PMD reason on this call | has(assignment.collections.call_log.latest.data.pmd_reason) |
| Skip if the Member has ever completed intake | member.collections.intake.count > 0 |
| Only fire if today's call was patient-care management | assignment.collections.call_log.latest.data.call_category == "patient_care_management" |
| Has this patient ever been hospitalized? | member.collections.hospitalizations.count > 0 |
assignment.form_data.* and assignment.data.* are not valid paths. Use member.form_data.<form>.<field> for singular Member-scoped data, or assignment.collections.<form>.* for current-Assignment collection records.
workspace
Workspace-scoped configuration data, keyed by DataType slug.
Available in: All contexts (event rules, ability conditions, router task conditions, edge conditions).
Access pattern: workspace.<slug>.<field>, where <slug> is the sanitized slug of a workspace-scoped DataType (one configured with record_scope="workspace"). The value reflects the field values of the latest workspace-scoped record for that DataType. Fields that have no record yet return their default values.
| Pattern | Type | Description |
|---|---|---|
workspace.<slug>.<field> | varies | Field value from the latest workspace-scoped record |
The record's created_at and external_id scalars are not exposed on this namespace today — only the field values from the record's data are surfaced. Use member- or assignment-scoped collection paths (e.g. member.collections.<slug>.latest.created_at) if you need record metadata.
Examples:
# Check a workspace configuration value
workspace.config.max_retries > 3
# Route based on a workspace setting
workspace.routing.default_queue == "tier1"
# Guard on a feature flag stored in a workspace DataType
workspace.feature_flags.beta_features_enabled == true
# Combine with member context
workspace.config.strict_mode == true && "admin" in member.labels
Setup: Workspace-scoped DataTypes are created in the workspace's Data Types settings with Record Scope set to "workspace". Each workspace-scoped DataType is always a collection; the CEL namespace reflects the latest record.
record
Information about the data record that triggered the event.
Available in: Data record event rules only (
data_record:created,data_record:updated,data_record:deleted).
| Field | Type | Description |
|---|---|---|
record.id | number | Record ID |
record.external_id | string | External ID (if set) |
record.data.{field} | varies | Field values from the record |
record.created_at | string | ISO 8601 creation timestamp |
record.updated_at | string | ISO 8601 last-update timestamp |
Examples:
# Check a record field value
record.data.status == "active"
# Check record has an external ID
record.external_id != ""
# Combine with member context
record.data.priority == "high" && "on-call" in member.labels
changes
Field-level change tracking for record updates.
Available in:
data_record:updatedevent rules only.
| Field | Type | Description |
|---|---|---|
changes.{field}.old | varies | Previous value of the field |
changes.{field}.new | varies | New value of the field |
Examples:
# Trigger when status changes to a specific value
changes.status.new == "Outreach"
# Trigger only when a field actually changed
changes.status.old != changes.status.new
# Transition-specific logic
changes.status.new == "Outreach" && changes.status.old != "Outreach"
datetime
Current time in your workspace's timezone.
Available in: All contexts.
| Field | Type | Description |
|---|---|---|
datetime.hour | number | Hour (0-23) |
datetime.minute | number | Minute (0-59) |
datetime.second | number | Second (0-59) |
datetime.day_of_week | number | Day (0=Monday, 6=Sunday) |
datetime.day_of_month | number | Day of month (1-31) |
datetime.month | number | Month (1-12) |
datetime.year | number | Year |
datetime.timestamp | string | ISO 8601 timestamp |
current_date | string | Date as "YYYY-MM-DD" |
current_time | string | Time as "HH:MM" |
is_business_hours | bool | Within workspace business hours |
Examples:
# Business hours only
is_business_hours
# Weekdays 9 AM - 5 PM
datetime.hour >= 9 && datetime.hour < 17 && datetime.day_of_week <= 4
# Specific date check
current_date == "2025-01-15"
tool_invocations
List of tools the AI called this turn. Each entry has name (string) and args (map of argument values).
Available in: Edge conditions on task transitions only. Evaluated after each agent turn.
| Field | Type | Description |
|---|---|---|
tool_invocations | list | List of tool calls made this turn |
tool_invocations[].name | string | Tool function name (e.g., "update_patient_info_record") |
tool_invocations[].args | map | Arguments passed to the tool |
Examples:
# Check if a specific tool was called
tool_invocations.exists(t, t.name == "update_patient_info_record")
# Check tool was called with a specific argument
tool_invocations.exists(t, t.name == "update_record" && has(t.args.status))
# Check argument value
tool_invocations.exists(t, t.name == "update_record" && t.args.status == "complete")
# Check if any tools were called
tool_invocations.size() > 0
Note: Tool names for Data Access abilities follow the pattern update_{data_type_slug}_record, create_{data_type_slug}_record, etc. Use has(t.args.field) to check field presence before comparing values.
Edge Routing: CEL condition fires but transition is blocked
A CEL condition evaluating to true does not guarantee the assignment will move to the target task. The transition can be blocked if the target task has its own entry guard — for example, an EventRule condition that rejects the entry request.
When this happens:
- The
transition_blockedoutcome is returned internally. - The assignment silently stays on the current task — no error message is shown to the member or the agent.
- A warning is logged server-side (visible in platform logs), but nothing is surfaced to the workflow author.
What to check when a CEL edge seems to fire but nothing happens:
- Confirm the CEL condition itself evaluates to
truefor the current turn (test it using the CEL editor's real-time validation). - Check the target task's entry conditions. In the workflow editor, open the target task and review its EventRules triggered on
task:entered— any one of those whose condition evaluates tofalsewill refuse entry and block the transition. - The block is recorded as a server-side
WARNINGlog line keyedcel_edge_evaluator.transition_blocked(including the assignment, source, and target task IDs plus the rejecting status code). This is not visible in the workspace UI — ask your workspace admin or Gravity Rail support to look it up by assignment ID.
Only client-side (4xx) refusals are silent. If the target task's entry instead fails with a server error (5xx), that error is re-raised rather than swallowed — so a transition that disappears with no error almost always points at an entry condition, not a platform fault.
See the Edge Routing feature doc for the full list of evaluation outcomes and the Debugging transition_blocked section for the exact log format.
message
The content of the current message.
Available in: Router tasks and message event rules only.
# Check message content
matches(message, "^[0-9]{5}$") # Valid zip code
contains(message, "urgent")
Built-in Functions & Macros
Standard CEL functions available in all contexts:
| Function | Description | Example |
|---|---|---|
has(field) | Check if a field exists and is set | has(member.phone) |
size(value) | Length of a string, list, or map | size(member.labels) > 0 |
exists(x, cond) | True if any list element matches | member.labels.exists(l, l == "vip") |
all(x, cond) | True if all list elements match | member.labels.all(l, l != "blocked") |
filter(x, cond) | Return matching list elements | member.labels.filter(l, l != "test") |
map(x, expr) | Transform each list element | member.labels.map(l, l + "_tag") |
startsWith(prefix) | String starts with prefix | member.phone.startsWith("+1") |
endsWith(suffix) | String ends with suffix | member.email.endsWith("@example.com") |
int(value) | Convert to integer | int("42") == 42 |
string(value) | Convert to string | string(member.id) |
type(value) | Return the type of a value | type(member.name) == string |
Examples:
# Check if member has a phone number before using it
has(member.phone) && member.phone != ""
# Check label list length
size(member.labels) >= 2
# Search within tool invocations (edge conditions)
tool_invocations.exists(t, t.name == "update_record" && has(t.args.status))
Custom Functions
| Function | Description | Example |
|---|---|---|
contains(text, substring) | Case-insensitive substring check | contains(member.email, "@gmail.com") |
notcontains(text, substring) | Inverse of contains | notcontains(message, "cancel") |
matches(text, pattern) | Regex pattern match | matches(message, "^[0-9]{5}$") |
containsEntryInFile(text, fileUuid) | Check against uploaded file entries | containsEntryInFile(message, "blocklist-uuid") |
Common Patterns
VIP Handling
"vip" in member.labels
Business Hours
is_business_hours
Form Completion Check
member.data.onboarding.status == "complete"
Channel-Specific Logic
chat.channel == "phone-sms"
chat.channel == "phone-voice" || chat.channel == "web-voice"
Combined Conditions
"premium" in member.labels && is_business_hours && !chat.is_test
Content Filtering
containsEntryInFile(message, "blocked-words-uuid") == false
Record Status Change
changes.status.new == "complete" && changes.status.old != "complete"
Tool Invocation Routing (Edge Conditions)
# Advance when a field is set
tool_invocations.exists(t, t.name == "update_intake_record" && has(t.args.profession))
# Route based on value
tool_invocations.exists(t, t.name == "update_intake_record" && has(t.args.location_type) && t.args.location_type == "fort")
# Boolean flag trigger
tool_invocations.exists(t, t.name == "update_intake_record" && has(t.args.done) && t.args.done == true)
Data Type Validation
CEL expressions can be used within Data Types to control field behavior and validate input.
Conditional Field Options
Use visibleWhen on enum options to show or hide choices based on other field values. The expression evaluates in the form editing context, where field names are available directly.
Example: Show California-specific options only when the state is California.
state == "CA"
This would be set on enum options that should only appear for California residents. When the user selects "CA" in the state field, these options become visible.
Field Validation
Use validateCel on fields to add custom validation rules. The expression should return:
trueif the value is valid- An error message string if the value is invalid
Access field values using record.data.<field>.
Example: Require age to be 18 or older.
record.data.age >= 18 ? true : "Must be 18 or older"
Example: Require email for non-SMS contacts.
record.data.contact_method != "sms" && record.data.email == "" ? "Email is required for non-SMS contacts" : true
Example: Validate date is in the future.
record.data.appointment_date > current_date ? true : "Appointment must be scheduled for a future date"
Tips
-
Use labels for permissions — Labels like
"vip","verified","admin"are the cleanest way to control access. -
Handle missing data — Use
has(field)to check if a field exists before accessing it. For example,has(member.phone) && member.phone != ""safely checks for a phone number. If a field doesn't exist and you access it directly, the expression evaluates tofalserather than erroring. -
Test your expressions — The CEL editor validates syntax in real-time. Invalid expressions show a red error indicator and cannot be saved.
-
Keep it readable — Break complex logic into multiple rules when possible.
-
All available functions appear in the editor — Click the "Functions" dropdown in the CEL editor toolbar to see all built-in and custom functions with signatures and examples.
Error Recovery
Circuit Breaker: Automatic Rule Disabling
If a CEL expression throws a runtime error on 5 or more consecutive evaluations, Gravity Rail automatically disables the event rule to prevent it from causing repeated failures. When this happens:
- The rule's
celBlockedAttimestamp is set to the time of the fifth failure. - The rule stops firing — it will not evaluate or trigger any actions until it is re-enabled.
- Other rules in the workspace are not affected.
Common causes:
- Accessing a field that doesn't exist on all members (use
has(field)to guard) - Calling
.slugon a label string (labels are plain strings, not objects — usel == "vip"notl.slug == "vip") - A type mismatch between the expression and the actual field value
How to recover:
- Open the event rule in the workspace editor and review the CEL expression.
- Fix the expression — refer to the Available Variables section and use
has()to guard optional fields. - Save the corrected expression. Saving clears
celBlockedAtand re-enables the rule immediately.
Tip: The CEL editor validates syntax in real-time, but runtime errors (e.g., a field that exists for some members but not others) only appear during actual evaluation. After fixing an expression, monitor the rule for a few minutes to confirm it is evaluating without errors.
Related
- Actions — Use CEL conditions to control when automations fire
- Abilities — Add CEL conditions to control when abilities activate
- Qualifications — Use CEL in formula criteria for evaluation