capless
Docs CEL functions

CEL functions

Capless uses CEL (Common Expression Language) everywhere logic runs on your data: stream validators, read filters and maps, erasure predicates and materialization mappings. One environment, deterministic and bounded.

Contexts and variables

ContextUsed byVariablesMust return
validatorPOST /v1/streams (validator)entrybool
filter/read, /changes, sourcesentry, id, cursor, batchId, committedAtbool
map/read, /changes, sourcesentry, id, cursor, batchId, committedAtany JSON value (sources: an object)
erasurePOST …/erasures (filter)entry, id, cursor, batchId, committedAtbool

entry is the JSON record (dyn). id, cursor and batchId are strings; committedAt is a timestamp (the batch's commit time). GET /v1/functions returns this description as JSON.

Try it: dry runs

curl -s https://api.capless.sh/v1/functions/evaluate -H "authorization: Bearer $CAPLESS_API_KEY" \
  -d '{"kind":"map","expression":"{\"user\": entry.user_id, \"cents\": int(entry.amount * 100.0)}","entry":{"user_id":"u1","amount":1.5}}'
# → {"result":{"user":"u1","cents":150}}

kind is validator, filter, map (default) or erasure; variables optionally sets id, cursor, batchId, committedAt.

Examples

# Validators
has(entry.user_id) && entry.user_id != ""
entry.amount >= 0 && entry.currency in ["EUR", "USD"]

# Filters
entry.type == "pageview" && entry.path.startsWith("/docs")
entry.type == "click" && has(entry.rage)
has(entry.utm) && entry.utm.source == "newsletter"
entry.sku.matches("^SKU-[0-9]{6}$")
committedAt >= timestamp("2026-10-29T00:00:00Z")
entry.items.exists(i, i.price > 100.0)

# Maps
{"user": entry.user_id, "total": double(entry.amount)}
{"path": entry.path, "lcp": has(entry.vitals) ? entry.vitals.lcpMs : null}
entry.tags.filter(t, t != "internal")
{"id": id, "committedAt": committedAt, "hourUtc": committedAt.getHours()}

# Erasures
entry.userId == "user_42"
entry.email.endsWith("@example.com")

Numbers

  • JSON numbers with an integral value are int; all others are double. 1 is an int, 1.5 and 1.0e3 are double.
  • Comparisons work across both (entry.amount > 1), but arithmetic does not mix them: entry.amount * 100 fails when amount is 1.5. Normalize with double(x) / int(x): double(entry.amount) * 100.0.
  • Integers beyond 2^53 stay exact end to end (storage, CEL, results).

Missing fields

Selecting a field that does not exist is an evaluation error. Guard with has():

has(entry.geo) && entry.geo.country == "DE"

On reads, an evaluation error fails the request (422 expression_evaluation_failed) unless you pass "onError": "exclude". In an erasure predicate, an error on an entry simply does not select it.

Regular expressions

matches takes a string-literal ECMAScript pattern. Patterns that could backtrack catastrophically are rejected at compile time (400 invalid_expression):

  • no backreferences (\1, \k<name>);
  • no lookaround ((?=…), (?!…), (?<=…), (?<!…));
  • no quantified group that contains a quantifier or an alternation: (a+)+, (a|aa)*, (.*a){3} are rejected; ^(foo|bar)-[a-z]{2,8}$ is fine.

Limits

LimitValue
Expression length4096 characters
AST nodes1000
Nesting depth32
Nested comprehension macros (all, exists, exists_one, map, filter)2
Literal list elements / map entries256 / 256
Records≤ 1 MiB, ≤ 32 levels

Every request also runs under the Workers CPU limit. Programs are compiled once and cached.

Determinism

There is no clock, randomness, I/O or user-defined function in the environment, so the same expression on the same entry always returns the same result. That is what makes materializations rebuildable and erasures exact.

This page as Markdown

← Time seeking · Erasures & GDPR →