CEL Expressions
The expression language used for formulas, predicates, schedules, and templated strings — surfaced via five tagged templates.
ObjectOS uses CEL (Common Expression Language) for every place where you need a small, safe, sandboxed expression: formula fields, validation rules, visibility predicates, sharing conditions, flow conditions, schedules, and templated strings.
Authoring is done through five tagged templates imported from
@objectstack/spec. They all produce a small JSON object the runtime
parses:
{ dialect: 'cel' | 'template' | 'cron', source: string }Schema source:
packages/spec/src/shared/expression.zod.ts.
The five tagged templates
| Template | Dialect | Use it for | Example |
|---|---|---|---|
F`...` | cel | Formula fields — derived values stored alongside the record | F`record.amount * 0.1` |
P`...` | cel | Predicates — booleans for validation / sharing / visibility / conditions | P`record.status == "open"` |
cel`...` | cel | General CEL — when neither F nor P fits (e.g. a parameter value) | cel`now() + duration("P30D")` |
tmpl`...` | template | String templates with {{var}} interpolation | tmpl`Order from {{record.customer.name}}` |
cron`...` | cron | Schedules — standard 5-field cron syntax | cron`0 9 * * 1-5` |
There is no functional difference between F, P, and cel at
evaluation time — they all run CEL. The split exists so that schemas
(and AI agents) know what role the expression plays and the editor can
type-check (formulas must return a value, predicates must return a
bool).
Import
import { F, P, cel, tmpl, cron } from '@objectstack/spec'Where each one is used
| Field on a spec | Tag | Example location |
|---|---|---|
A formula field's expression | F | *.object.ts formula fields |
A field's requiredWhen / visibleWhen / readonlyWhen | P | object fields |
A script validation rule's condition | P | object validations |
SharingRule.condition | P | sharing rules |
A list view's conditionalFormatting[].condition | P | views |
A start node's config.condition, an edge's condition | P | flows |
An action's visible / disabled | P | actions |
A schedule's expression | cron | scheduled flows and jobs |
An object's titleFormat (deprecated) and AI prompt templates | tmpl | objects, AI models |
A field's defaultValue | cel | object fields |
Variable scope
CEL expressions evaluate in a context with these top-level variables:
| Variable | Available when | Contents |
|---|---|---|
record | almost always | The current record being evaluated |
previous | on update hooks / change-detection | The pre-change state of the record (or null) |
input | actions, flow steps | The user-supplied input payload |
os.user | always | { id, name, email, positions: string[], organizationId } |
os.org | always | Organisation / tenant context |
os.env | always | Environment variables exposed to expressions |
Legacy
OLD/NEWvariables were removed in M9.5. Usepreviousandrecord.
Standard library
Registered in
packages/formula/src/stdlib.ts.
The most-used built-ins:
Time
| Function | Returns | Notes |
|---|---|---|
now() | Timestamp | Pinned to the evaluation context — stable within one query |
today() | Timestamp | Start of UTC day |
daysFromNow(int) | Timestamp | Future date |
daysAgo(int) | Timestamp | Past date |
CEL also includes the native timestamp(...), duration(...),
date.getDayOfWeek(), etc. — see the
CEL spec.
Utility
| Function | Purpose |
|---|---|
isBlank(x) | true if null, undefined, "", or empty list |
coalesce(a, b) | First non-null |
trim(s) | Strip whitespace |
joinNonEmpty(list, sep) | Concatenate non-empty entries |
Native CEL string helpers (.contains(...), .startsWith(...),
.matches(...), .size()) are always available.
Examples
Formula field — line-item total:
subtotal: Field.formula({
label: 'Subtotal',
expression: F`record.quantity * record.unit_price`,
}),Validation — close date must be in the future. A script rule's
condition describes the invalid record: when it is true, the write is
refused.
// One validation rule, an entry of the object's `validations`
{
type: 'script',
name: 'close_date_in_future',
message: 'Close date must be in the future',
condition: P`record.close_date <= today()`,
}Visibility — show a field only once the deal is won:
close_reason: Field.textarea({
label: 'Close reason',
visibleWhen: P`record.stage == 'closed_won'`,
}),Flow condition — start a record-change flow only for large amounts:
// The `config` of a `start` node; the rest of the flow is omitted (see Flows).
config: {
objectName: 'opportunity',
triggerType: 'record-after-update',
condition: P`record.amount >= 1000`,
}Schedule — weekday 9am:
// The `config` of a `start` node; the rest of the flow is omitted (see Flows).
config: {
triggerType: 'schedule',
schedule: { type: 'cron', expression: cron`0 9 * * 1-5` },
}Notification text is not a tmpl slot. A notify node's title and
message are plain strings with {record.field} placeholders:
// The `config` of a `notify` node; the node around it is omitted.
config: {
recipients: '{record.owner}',
title: '[{record.priority}] {record.subject}',
}Errors
Expressions are compiled at load time. Failures show up as
VALIDATION_ERROR with the source location:
{ "code": "VALIDATION_ERROR", "message": "CEL: unknown field 'amout' on Record", "details": { "field": "subtotal", "expression": "record.amout * 0.1" } }Invalid expressions do not fail silently. A malformed or unknown-field
expression fails os compile with the located message above (including a
did-you-mean hint for a misspelled field). At runtime, a bad expression
throws an attributed error rather than silently evaluating to null or
false, so the failure is visible in logs and the audit trail instead of
quietly corrupting a formula value or a guard decision.
See also
- Field types — formula fields and
requiredWhen - Build → Data model — validations and predicates
- Build → Flows — conditions and schedules
@objectstack/spec/shared/expression.zod.ts— schema@objectstack/formula/stdlib.ts— built-ins