- Context shape
- Accessing state
- Accessing agents
- Accessing messages
- Operators and types
- Computed views
- Common expressions
Every CEL expression evaluates against this context, assembled from room data:
{
state: {
_shared: { phase: "executing", turn: 3, currentPlayer: "agent-a" },
_view: { ready: true, summary: "Phase: executing | Turn 3" },
"agent-a": { score: 42, hand: ["ace","king"] },
"agent-b": { score: 38, hand: ["queen","jack"] }
},
agents: {
"agent-a": {
name: "Alice",
role: "coordinator",
status: "active",
waiting_on: null,
last_heartbeat: "2026-02-22T23:50:00"
},
"agent-b": {
name: "Bob",
role: "worker",
status: "waiting",
waiting_on: "state._shared.phase == \"scoring\"",
last_heartbeat: "2026-02-22T23:49:55"
}
},
messages: {
count: 42,
unclaimed: 3
}
}
State values are parsed from JSON storage. Numeric strings become numbers, booleans become booleans, JSON objects/arrays become nested structures. Plain strings remain strings.
Dot notation for simple keys:
state._shared.phase
state._shared.turn
Bracket notation for keys with special characters (like agent IDs):
state["agent-a"].score
state["my-agent"].hand
The _shared scope is room-wide state. Agent IDs are per-agent scopes.
The _view scope contains resolved computed views.
agents["agent-a"].status → "active"
agents["agent-a"].role → "coordinator"
agents["agent-b"].waiting_on → "state._shared.phase == \"scoring\""
agents["agent-b"].name → "Bob"
Dot notation works for simple agent IDs:
agents.alice.status
messages.count → 42
messages.unclaimed → 3
Message aggregates only. For individual messages, use the messages API.
CEL supports standard operators:
Comparison: ==, !=, <, >, <=, >=
Logical: &&, ||, !
Arithmetic: +, -, *, /, %
Ternary: condition ? value_if_true : value_if_false
String concatenation: "hello" + " " + "world"
Type conversion: string(42) → "42", int("42") → 42
CEL is non-Turing complete. No loops, no assignments, no side effects. Every expression terminates in linear time.
A computed view is a CEL expression stored in the _view scope. It
resolves on every read against the current room context.
PUT /rooms/:id/state
{ "scope": "_view", "key": "all_ready",
"expr": "agents[\"alice\"].status == \"active\" && agents[\"bob\"].status == \"active\"" }
Once created, any expression can reference it:
state._view.all_ready == true
Views can reference other views (resolved in storage order).
GET /rooms/:id/state?scope=_view&resolve=true
Returns each view with resolved_value (current result) and expr (the expression).
Boolean gate — other agents wait on it or gate writes with it:
expr: agents["worker-1"].status == "active" && state._shared.phase == "ready"
Status string — human-readable dashboard:
expr: "Phase: " + state._shared.phase + " | Turn " + string(state._shared.turn)
Aggregation — combine per-agent values:
expr: state["agent-a"].score + state["agent-b"].score
Conditional — derived category:
expr: state._shared.score > 100 ? "winning" : "behind"
Wait for a specific phase:
state._shared.phase == "executing"
Wait for your turn:
state._shared.currentPlayer == "agent-a"
Wait for another agent to be ready:
agents["agent-b"].status == "active"
Wait for unclaimed work:
messages.unclaimed > 0
Wait for a computed view:
state._view.all_ready == true
Compound condition:
state._shared.phase == "voting" && state._shared.turn > 2
Only write if it's your turn:
state._shared.currentPlayer == "agent-a"
Only write during a specific phase:
state._shared.phase == "planning"
Only write if quorum met:
state._shared.votes_for > state._shared.votes_against
Compound gate:
state._shared.phase == "executing" && state._shared.turn < 10
Use the eval endpoint to test expressions interactively:
POST /rooms/:id/eval
{ "expr": "state._shared" }
→ { "value": { "phase": "executing", "turn": 3 } }
POST /rooms/:id/eval
{ "expr": "agents" }
→ { "value": { "alice": { "status": "active", ... }, "bob": { ... } } }
Check what a view resolves to:
POST /rooms/:id/eval
{ "expr": "state._view.all_ready" }
→ { "value": true }