> ## Documentation Index
> Fetch the complete documentation index at: https://docs.scrip.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# CEL Expressions

> Write rule conditions and action-field expressions with CEL

Scrip uses [CEL (Common Expression Language)](https://github.com/google/cel-spec) for rule conditions and dynamic action fields. You write conditions to control when a rule fires, and you wrap action-field expressions in `${{ ... }}` to compute amounts, counter values, attributes, reference IDs, or dynamic targets.

```javascript theme={null}
// Condition (returns boolean)
event.type == "purchase" && event.amount > 50.0
```

```json theme={null}
{"type": "CREDIT", "asset_id": "...", "amount": "${{ event.amount * 0.10 }}"}
```

## Operators

| Operator        | Example                                                      |
| --------------- | ------------------------------------------------------------ |
| Equality        | `event.type == "purchase"`                                   |
| Inequality      | `event.type != "refund"`                                     |
| Comparison      | `event.amount > 100.0`, `event.amount >= 50.0`               |
| Logical AND     | `event.type == "purchase" && event.amount > 0`               |
| Logical OR      | `event.category == "dining" \|\| event.category == "travel"` |
| Negation        | `!("vip" in participant.tags)`                               |
| Containment     | `"vip" in participant.tags`                                  |
| List membership | `event.category in ["dining", "travel"]`                     |
| Ternary         | `event.tier == "gold" ? 100.0 : 50.0`                        |

<Warning>
  Use `==` for equality, not `=`. Using `=` is a syntax error.
</Warning>

## Available Variables

When a rule evaluates, these variables are available in your conditions and amount expressions.

### `event`

The `event_data` payload from your API request. Fields depend on what you send, so every expression you write is specific to your event schema.

```javascript theme={null}
event.type == "purchase"
event.amount > 50.0
event.category in ["dining", "travel", "entertainment"]
event.items.size() > 0
event.items[0].sku == "ABC123"
```

Numeric values in your payload work directly in normal arithmetic and comparisons:

```javascript theme={null}
event.amount * 0.10       // works, amount is already a number
event.quantity * 5.0       // works
```

If a numeric value is sent as a string in your payload, cast it with `double()`:

```javascript theme={null}
double(event.amount) * 0.10  // only needed if amount is "49.99" not 49.99
```

Modulo (`%`) is the exception because CEL requires integers; cast event numbers with `int()` first.

#### Numeric magnitude limit

CEL evaluates JSON numbers as 64-bit floats, which are only exact up to a magnitude of 2^53 (9,007,199,254,740,992). To protect ledger precision, event ingestion and rule simulation reject any JSON number in `event_data` above that magnitude with `400 amount_precision_exceeded`. If you need to carry larger values, send them as strings; string values pass through exactly. The same boundary applies at execution time: an amount expression whose *result* exceeds 2^53 fails the action rather than posting an imprecise amount.

#### Optional fields

Not every event has the same shape. If a condition references a field that isn't in the payload, the condition evaluates to `false`, the rule does not match, and no rule-evaluation row is recorded for that rule. Use `has()` to check for a field before accessing it:

```javascript theme={null}
has(event.referrer_id) && event.referrer_id != ""
has(event.coupon_code) && event.coupon_code == "SUMMER25"
```

If a matched rule's action expression reads a missing field, the behavior is different: the action fails and the event is marked `FAILED`.

### `participant`

The participant's current state at the time of evaluation. Most rules use this to check tags, counters, or attributes before granting rewards.

```javascript theme={null}
participant.tags        // list of strings
participant.counters    // map of string to number
participant.attributes  // map of string to string
participant.tiers       // map of string to tier state
participant.balances    // map of string to available balance by asset symbol

// Dot-access shorthand (recommended): safe defaults, no get() needed
participant.counter.<name>    // number, defaults to 0 if unset
participant.tag.<name>        // bool,   defaults to false if unset
participant.attribute.<name>  // string, defaults to null if unset
participant.balance.<symbol>  // number for program-linked assets
```

#### Identity

Participant identity fields are available alongside state:

```javascript theme={null}
participant.id           // Scrip participant UUID (string)
participant.external_id  // your application's user ID (string)
participant.status       // "ACTIVE", "SUSPENDED", or "CLOSED"
participant.enrolled_at  // CEL timestamp of program enrollment
participant.created_at   // CEL timestamp of participant creation
```

`enrolled_at` and `created_at` are CEL timestamps, so date math works directly without `timestamp()`:

```javascript theme={null}
// Within 30 days of enrollment
duration_days(now - participant.enrolled_at) <= 30.0
```

For program-scoped events with no participant, identity values are absent and a condition that reads them is a clean non-match.

#### Tags

```javascript theme={null}
participant.tag.vip                 // shorthand: true if present, false if not
"vip" in participant.tags           // membership check (equivalent for presence)
!("welcome_bonus" in participant.tags)
```

<Note>
  `"x" in participant.tag` (and `"x" in participant.counter`) reflects whether a key is actually set, while `participant.tag.<name>` returns the default (`false` / `0`) for unset keys, and `has(participant.counter.x)` is always `true`. Use `in` when you need a real presence check.
</Note>

#### Counters

Use the `participant.counter.<name>` shorthand. A missing counter defaults to `0`, so you don't need `get()`:

```javascript theme={null}
participant.counter.purchase_count >= 10
participant.counter.lifetime_spend > 1000

// Equivalent explicit form (still supported):
get(participant.counters, "purchase_count", 0.0) >= 10.0
```

Counter keys that aren't valid identifiers (hyphens, leading digits, spaces) need bracket access: `participant.counter["q1-2026"]`.

Counter values are always evaluated numerically, even when the source carried them as decimal strings (the wire format the participant state endpoints return). You can copy a counters map from a state read into a [simulation request](/api-reference/rules/simulate-a-rule) as-is, with no casting needed.

#### Attributes

A missing attribute via `participant.attribute.<name>` is `null` (no error):

```javascript theme={null}
participant.attribute.region == "US"
participant.attribute.plan in ["pro", "enterprise"]

// Equivalent explicit form (still supported):
get(participant.attributes, "region", "") == "US"
```

#### Balances

Use `participant.balance.<symbol>` to read the participant's available ledger balance for a program-linked asset:

```javascript theme={null}
participant.balance.credits < 5000.0
participant.balance.credits - double(event.cost) < 5000.0
```

Every asset linked to the program is loaded with a real value. A linked but unfunded asset reads as `0`. An unknown symbol is strict: in a condition it becomes a clean non-match, and in an action expression it fails the action. Even `get(participant.balances, "unknown", 0.0)` does not substitute the default for an unknown symbol.

Balances are snapshotted at event start, like counters. Actions from earlier rules in the same event are not visible to later rule conditions, so use the projected-balance form above when you need to detect a same-event crossing.

Balance references are also validated when you save a rule. A symbol not linked to the rule's program is rejected with a `400` such as `unknown asset symbol "gems" (linked to program: credits, points)`. In automation `participant_filter` and `guard_condition` expressions, balance references are rejected at save entirely.

### `program`

The program's current state. Supports the same `tags`, `counters`, and `attributes` as participants. Use this for global logic that isn't tied to any one participant, like a program-wide redemption cap.

```javascript theme={null}
program.id                                       // the program's public UUID (string)
program.name                                     // program display name
"milestone_reached" in program.tags
program.counter.total_issued < 100000            // shorthand, defaults to 0
program.attribute.region == "US"                 // shorthand, defaults to null

// Explicit form (still supported):
get(program.counters, "total_issued", 0.0) < 100000.0
```

### `groups`

List of groups the participant belongs to. Each entry has `id`, `name`, `tags`, `counters`, `attributes`, and `tiers`, and supports the same `counter`/`tag`/`attribute` shorthand.

```javascript theme={null}
groups.size() > 0
groups.exists(g, g.name == "VIP Customers")
groups.exists(g, g.counter.team_purchases < 100)   // shorthand
```

<Note>
  A participant can belong to multiple groups, so prefer `groups.exists(g, ...)` over assuming a specific list position.
</Note>

### `now`

The event's `event_timestamp` as a CEL timestamp. Scrip uses the event timestamp rather than wall-clock time so that evaluation stays deterministic across retries and reprocessing.

```javascript theme={null}
now > timestamp("2025-01-01T00:00:00Z")
now < timestamp("2025-12-31T23:59:59Z")
```

## Quick Reference

| Data                  | Pattern                                                |
| --------------------- | ------------------------------------------------------ |
| Event field (number)  | `event.amount > 50.0`                                  |
| Event field (string)  | `event.type == "purchase"`                             |
| Optional event field  | `has(event.referrer_id) && event.referrer_id != ""`    |
| Tag check             | `participant.tag.vip` or `"vip" in participant.tags`   |
| Counter               | `participant.counter.purchase_count >= 10`             |
| Attribute             | `participant.attribute.region == "US"`                 |
| Balance               | `participant.balance.credits < 5000.0`                 |
| Participant status    | `participant.status == "ACTIVE"`                       |
| Days since enrollment | `duration_days(now - participant.enrolled_at) <= 30.0` |
| Group membership      | `groups.exists(g, g.name == "VIP Customers")`          |
| Timestamp comparison  | `now > timestamp("2025-01-01T00:00:00Z")`              |
| Program counter       | `program.counter.total_issued < 1000`                  |

## Helper Functions

Scrip adds `get()`, `round()`, and the `duration_*` helpers on top of standard CEL for safe map access, rounding, and time calculations. `has()` and `timestamp()` are standard CEL functions, listed here because most rules use them.

### `get(map, key, default)`

Reads a value from a map, returning `default` if the key doesn't exist. For counters, tags, and attributes, prefer the dot-access shorthand (`participant.counter/tag/attribute.<name>`), which bakes in safe defaults. Tiers have no shorthand: read a known tier with `participant.tiers.<key>.<field>`, and use `get()` with a default **object** when the tier may be unset (so the field access stays safe).

```javascript theme={null}
participant.tiers.loyalty.level == "gold"                        // known tier, dot-access
get(participant.tiers, "status", {"level": ""}).level == "gold"  // safe if the tier may be unset
get(participant.counters, "spend", 5.0) > 100.0                  // counter with a custom default
```

<Warning>
  Don't index the plural maps directly for a key that may be missing (`participant.counters["spend"]`); use the shorthand or `get()`, and see [Optional fields](#optional-fields) for missing-key behavior.
</Warning>

### `round(value, scale)`

Rounds a number to the specified decimal places.

```javascript theme={null}
round(event.amount * 0.03, 2)   // 49.99 * 0.03 = 1.4997 → 1.50
round(event.amount, 0)          // 49.99 → 50.0
```

### `duration_hours(duration)`

Converts a CEL duration to hours. You get a duration by subtracting two timestamps. Multiply by 60 for minutes.

```javascript theme={null}
// Less than 1 hour old
duration_hours(now - timestamp(event.created_at)) < 1.0
```

### `duration_days(duration)`

Converts a CEL duration to days.

```javascript theme={null}
// More than 30 days since activation
duration_days(now - timestamp(event.activation_date)) > 30.0
```

### `duration_weeks(duration)`

Converts a CEL duration to weeks.

```javascript theme={null}
// More than 2 weeks since the last activity
duration_weeks(now - timestamp(event.last_activity_at)) > 2.0
```

### `has(field)`

Returns `true` if a field exists on an object. Use this for event fields that only appear on some event types.

```javascript theme={null}
// Only match if the event includes a coupon code
has(event.coupon_code) && event.coupon_code == "SUMMER25"
```

### `timestamp(string)`

Parses an RFC 3339 string (e.g., `"2025-06-01T00:00:00Z"`) into a CEL timestamp for comparison and arithmetic.

```javascript theme={null}
timestamp(event.purchase_date) >= timestamp("2025-06-01T00:00:00Z")
now - timestamp(event.activation_date)  // produces a duration
```

## Extensions

Scrip enables the CEL math, sets, and strings extension libraries.

### Math

Min/max capping, absolute value, and rounding. Useful for bounding dynamic amounts to a floor or ceiling.

```javascript theme={null}
math.least(event.amount * 0.1, 50.0)     // cap: 10% of amount, max 50
math.greatest(event.amount * 0.05, 10.0)  // floor: 5% of amount, min 10
math.abs(-42.0)                            // 42.0
math.ceil(3.2)                             // 4.0
math.floor(3.8)                            // 3.0
```

### Sets

Membership checks across lists for tag-based conditions.

```javascript theme={null}
sets.contains(participant.tags, ["vip", "gold"])     // participant has ALL of these tags
sets.intersects(participant.tags, ["vip", "silver"])  // participant has ANY of these tags
```

### Strings

The strings extension (v4) adds member-style functions on any string value: `charAt`, `indexOf`, `lastIndexOf`, `lowerAscii`, `upperAscii`, `replace`, `split`, `substring`, `trim`, `join`, `format`, and `reverse`, plus the global `strings.quote(s)`.

```javascript theme={null}
// Case-insensitive coupon match
event.code.lowerAscii() == "promo"
```

## Expressions in action fields

Action string fields use one explicit convention:

> A value is CEL when it is wrapped in `${{ ... }}`. Anything else is a literal.

```json theme={null}
{
  "name": "Cashback Reward",
  "condition": "event.type == 'purchase'",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "...",
      "amount": "${{ round(event.amount * 0.03, 2) }}",
      "reference_id": "purchase-${{ event.order_id }}"
    }
  ]
}
```

In this example, a \$105 purchase evaluates `round(105.0 * 0.03, 2)` and credits 3.15 points.

Fields that accept `${{ }}` include `amount`, COUNTER `value`, SET\_ATTRIBUTE `value`, `reference_id`, `target.external_id`, and `target.participant_id`.

| Value                                          | Behavior                                                                      |
| ---------------------------------------------- | ----------------------------------------------------------------------------- |
| `"100"`                                        | Literal static amount                                                         |
| `"north_america"`                              | Literal string                                                                |
| `"${{ event.amount * 0.10 }}"`                 | Whole-string template. The result keeps the expression type.                  |
| `"${{ round(event.amount * 0.03, 2) }}"`       | Numeric amount, rounded to 2 decimal places                                   |
| `"purchase-${{ event.order_id }}"`             | Mixed template. Expression results are converted to strings and concatenated. |
| `"${{ event.tier == 'gold' ? 100.0 : 50.0 }}"` | Conditional amount                                                            |

Amount results are rounded to the asset's configured `scale` before being applied.

<Warning>
  Legacy unmarked expressions such as `"amount": "event.amount * 0.10"` still work for stored-rule compatibility, but they are deprecated. Use `${{ ... }}` in new rules. For string literals that contain operator characters, wrap a quoted string expression, such as `"value": "${{ 'us-west' }}"`.
</Warning>

## Common Patterns

These patterns come up in most programs. Each one is a complete condition you can use directly or adapt.

### One-time gate

Grant a reward once, then tag the participant to prevent re-triggering.

```javascript theme={null}
event.type == "signup" && !("welcome_bonus" in participant.tags)
```

### Threshold crossing

Detect the event that pushes a counter past a target. Counters reflect pre-event state, so check the current value plus the incoming amount.

```javascript theme={null}
participant.counter.spend < 1000.0 &&
(participant.counter.spend + event.amount) >= 1000.0
```

### Milestone (Nth occurrence)

Fire on exactly the Nth event of a type. The counter hasn't incremented yet, so add 1 to get the count including this event.

```javascript theme={null}
event.type == "ride_complete" &&
(participant.counter.ride_count + 1.0) == 10.0
```

### Date range

Restrict a rule to events within a specific window.

```javascript theme={null}
timestamp(event.purchase_date) >= timestamp("2025-06-01T00:00:00Z") &&
timestamp(event.purchase_date) < timestamp("2025-07-01T00:00:00Z")
```

### Days since a date

Check elapsed time between two dates with `duration_days`.

```javascript theme={null}
duration_days(now - timestamp(event.activation_date)) <= 30.0
```

### Capped bonus

Reward a percentage of the event amount but cap the maximum payout.

```javascript theme={null}
math.least(event.amount * 0.1, 50.0)
```

### Category-specific

Only match events in certain categories.

```javascript theme={null}
event.type == "purchase" && event.category in ["dining", "travel"]
```

### Segment-specific

Use tags to restrict rules to a participant segment.

```javascript theme={null}
event.type == "purchase" && "vip" in participant.tags
```

### Exclude already-rewarded

Prevent a participant from claiming a promotion more than once.

```javascript theme={null}
event.type == "purchase" && !("promo_claimed" in participant.tags)
```

## Common Gotchas

| Mistake                                                                         | Fix                                                                                                                                                                                                                                                                                                                                                                            |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `event.type = "purchase"`                                                       | Use `==` not `=`                                                                                                                                                                                                                                                                                                                                                               |
| `event.amount` when amount is a string                                          | Use `double(event.amount)`                                                                                                                                                                                                                                                                                                                                                     |
| Referencing `event.field` that may not exist                                    | Condition returns `false`. Use `has(event.field)` if the rule should still match.                                                                                                                                                                                                                                                                                              |
| `event.qty % 2 == 0`                                                            | Cast event numbers before modulo: `int(event.qty) % 2 == 0`                                                                                                                                                                                                                                                                                                                    |
| Indexing a missing counter/attribute key directly (`participant.counters["x"]`) | Use the shorthand `participant.counter.x` (defaults to `0`) or `get()`; raw indexing is a quiet non-match in conditions and a failure in action expressions.                                                                                                                                                                                                                   |
| Checking state updated earlier in the same event                                | Conditions use the event-start snapshot. For counters, use the [threshold crossing](#threshold-crossing) or [Nth occurrence](#milestone-nth-occurrence) pattern. For tags, attributes, or tiers, put the dependent action in the same rule or trigger it on a later event. See [State Snapshot Evaluation Behavior](/guides/writing-rules#state-snapshot-evaluation-behavior). |
