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
| Context | Used by | Variables | Must return |
|---|---|---|---|
validator | POST /v1/streams (validator) | entry | bool |
filter | /read, /changes, sources | entry, id, cursor, batchId, committedAt | bool |
map | /read, /changes, sources | entry, id, cursor, batchId, committedAt | any JSON value (sources: an object) |
erasure | POST …/erasures (filter) | entry, id, cursor, batchId, committedAt | bool |
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 aredouble.1is anint,1.5and1.0e3aredouble. - Comparisons work across both (
entry.amount > 1), but arithmetic does not mix them:entry.amount * 100fails whenamountis1.5. Normalize withdouble(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
| Limit | Value |
|---|---|
| Expression length | 4096 characters |
| AST nodes | 1000 |
| Nesting depth | 32 |
Nested comprehension macros (all, exists, exists_one, map, filter) | 2 |
| Literal list elements / map entries | 256 / 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.