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.