State & Scopes
All state lives in scopes: a flat namespace of reactive containers that
expressions read and set writes. An expression addresses state by scope name
and field:
scopes.<scopeName>.<field> // e.g. scopes.root.searchTermWhat a scope is
A scope is one shallow Proxy over a set of fields, with no state library
underneath. Everything under a field is plain data. root and the scopes you
add are createProxyScope objects; an item scope is the
runtime’s own (below).
const root = createProxyScope<{ count: number; user?: { name: string } }>({ count: 0 });
root.$$emitter.on("user", () => { /* … */ }); // subscribe to a field
root.count = 1; // write — emits "count"
root.$$set("user", { name: "Ada" }); // the same, as a call
root.$$set("count", 0, { default: true }); // no-op — writes only if unset
root.user.name = "Hopper"; // ⚠️ a plain write into plain data — nobody hears itA write replaces one field and emits it. There is no deep write: to change part
of an object, write the object. A seed step writes with
default: true, so two entries seeding one field do not overwrite each other.
The root scope
scopes.root is always present and is the only app-wide scope: search terms,
the active tab, which modal is open.
{ "key": "search", "component": "SearchInput",
"seed": [{ "set": "scopes.root.searchTerm", "literal": "" }],
"props": { "expr": "({ value: scopes.root.searchTerm })" },
"callbacks": { "onChange": [{ "set": "scopes.root.searchTerm", "expr": "evt.value" }] } }Every other scope belongs to a list item or comes from the host. root belongs
to the <RendererProvider>, not to one document, so every
document under one provider shares it. That is how chat blocks
share live state.
Item scopes
A list gives each item two scopes, named by as.
With "as": "order":
scopes.orderis the item, a window onto the element in the array, and nothing else.scopes.$orderis the row: the runtime’s read-onlyindex,idandvalue, and the row’s own state.
{ "key": "orders", "component": "Card",
"each": "scopes.root.orders", "as": "order", "keyBy": "id",
"children": ["order-title"] }
{ "key": "order-title", "component": "Typography",
"props": { "expr": "({ text: (scopes.$order.index + 1) + '. ' + scopes.order.customer })" } }index is the zero-based position in the each result, and id the key
keyBy picked (the index without keyBy). When the array holds strings or
numbers, the item is value. An as cannot start with $.
A write to the item replaces it in the array. The item itself never
changes. set: "scopes.order.price" does, behind the scenes:
scopes.root.orders = scopes.root.orders.map((o) => (o === thisOrder ? { ...o, price } : o));The array is the one each reads: scopes.root.orders, also when each is
scopes.root.orders.filter(...). In a nested list the write climbs: a line
edit sets a new scopes.order.lines, which sets a new scopes.root.orders.
When each builds new objects, as orders.map(o => ({ ...o, total }))
does, no array holds the item, and the write fails as unknown-reference. A
primitive item cannot be written.
Scopes stack, so a list’s as must be a new name: not root, and not the
as of a list it sits inside. A reused name fails as invalid-list in the
list’s error slot. Lists side by side do not stack, so they
may share a name.
// ❌ invalid-list: inside an order, "scopes.item" already exists
{ "key": "orders", "component": "Card", "each": "scopes.root.orders", "as": "item", "children": ["lines"] }
{ "key": "lines", "component": "Typography", "each": "scopes.item.lines", "as": "item",
"props": { "expr": "({ text: scopes.item.sku })" } }
// ✅ distinct names stack — a line sees root, order and line at once.
{ "key": "orders", "component": "Card", "each": "scopes.root.orders", "as": "order", "children": ["lines"] }
{ "key": "lines", "component": "Typography", "each": "scopes.order.lines", "as": "line",
"props": { "expr": "({ text: scopes.order.id + ' / ' + scopes.line.sku })" } }Per-row state
State about a row that is not its data, such as expanded or editing, goes in
the row’s own scope, scopes.$<as>. It starts empty and never touches the
item:
{ "key": "orders", "component": "Accordion",
"props": { "literal": { "type": "multiple" } }, "children": ["order"] }
{ "key": "order", "component": "AccordionItem",
"each": "scopes.root.orders", "as": "order", "keyBy": "id",
"props": {
"expr": "({ title: scopes.order.customer, open: scopes.$order.open })"
},
"callbacks": { "onToggle": [
{ "set": "scopes.$order.open", "expr": "evt.open" }
] } }A write re-renders that row only. The state is kept by the row’s id, so it
survives a reorder, a refetch, and a filter that hides the row and shows it
again; it lasts as long as the list. The row and everything inside it can read
it, and the rest of the page cannot.
State read outside the row
State that something outside the row reads or writes, such as a count of
selected rows or “select all”, lives at root, keyed by the row’s id:
{ "key": "order-check", "component": "Checkbox",
"each": "scopes.root.orders", "as": "order", "keyBy": "id",
"seed": [{ "set": "scopes.root.selected", "literal": {} }],
"props": {
"expr": "({ checked: scopes.root.selected[scopes.$order.id] })"
},
"callbacks": { "onChange": [{
"set": "scopes.root.selected",
"expr": "({ ...currentValue, [scopes.$order.id]: evt.checked })"
}] } }selected holds one flag per order. The list’s seed runs once, not once per
row. A write to the map wakes every row, but only rows whose props changed
re-render.
Reading and writing
- Reads are property access at any depth:
scopes.root.count,scopes.order.total,scopes.root.user.address.city. - Writes happen only through
set, in aseedstep at mount or acallbacksstep on an event. See assignments.
A set address is scopes.<scope>.<field>: never deeper, never a number.
{ "set": "scopes.root.rows", "expr": "listRows()" } // ✅ a field
{ "set": "scopes.root.filters", "expr": "({ ...scopes.root.filters, status: 'open' })" } // ✅ part of a field: write the field
{ "set": "scopes.root.filters.status", "literal": "open" } // ❌ rejected — deeper than a field
{ "set": "scopes.root.rows.0.qty", "literal": 3 } // ❌ rejected — a numberA write wakes every reader of the field, at any depth: writing filters wakes a
reader of filters.status. See Reactivity.
Lifecycle
A seeded field persists for the life of the page, through re-renders and while
its element is hidden. A field never seeded reads as undefined.
A seed is first-writer-wins: it assigns only if the field is still
undefined.
{ "key": "panel", "component": "Card", "seed": [{ "set": "scopes.root.sort", "literal": "name" }],
"children": ["label"] }
{ "key": "label", "component": "Typography", "seed": [{ "set": "scopes.root.sort", "literal": "price" }],
"props": { "expr": "({ text: scopes.root.sort })" } }Renders name: panel seeds before label mounts, so label’s seed is a
no-op, not an error. The same set in a callbacks step would write price.
This is also why a value the user changed survives a re-mount.
The host can also supply state.
<RendererProvider init>
preloads scopes.root once, before any entry evaluates, and can
expose extra named scopes (read as
scopes.userCtx.name): proxies the host keeps and can update later. Steps can
write them too, as they write root.
Declare each extra scope in the note of getCommonInstructionsPartialPrompt,
which lands as a ## Note under the prompt’s # Expression Context. The note
is all the model sees of the scope, so spell out its shape.