One home for what the user decided
The platform keeps every durable user choice in one home — a per-user, per-app, per-key record store — under one contract: the admin's config grants the space; the user chooses within it. Every turn runs from the intersection.
A signed-in user decides things about how an app behaves for them: which tools the agent uses, which model answers, whether durable memory participates at all. Those decisions need to survive the conversation, the device, and the restart — and apply fresh on every turn. The platform keeps them all in one home: a per-user, per-app, per-key settings construct, with one contract that makes every setting safe.
one row per decision
(user_id, bundle_id, key) → value_json [+ subsystem = the owning store]
Everything rides one table — user_bundle_props — and every kind of setting is
a store: a thin typed layer that owns one record shape (its key convention,
its schema_version, its defaults, its write rules). An app adds its own settings
by adding a store; rows are never written ad hoc.
The contract: config grants, the user chooses within
The admin's config declares the space of possibilities; the user's record holds their choices inside it. The two meet on every turn:
Four rules hold for every store:
- Clamp on write, intersect on read. A choice outside what config grants
is stripped at write time, and reads recompute
effective = configured ∩ chosen— so a stale stored choice for a since-removed config entry is a harmless no-op. - Defaults flow from config, never from storage. An absent row or field means "the configured default": new config entries apply to everyone immediately, and no migration ever back-fills rows.
- Merge-writes never clobber siblings. A write carries only what changed; the store merges it over the stored record. Toggling one tool never touches the model pick — and two interleaved partial writes both land, because neither rewrites the other's fields.
- Per-turn reads, fail open. The runtime reads the record fresh at the turn's application point and treats every failure as "use the configured behavior" — a broken settings store never breaks the app.
The shipped proof
Two stores exercise the construct end to end.
Memory preferences (subsystem='memory') — the user's memory
posture: participate in durable memory at all, and at what read scope. One convention worth
copying: the row uses bundle_id='*', so a single record governs the user's memory
behavior across every app.
The agent selection record (subsystem='agents') — everything
a user decided about one configured agent, one record per (user, app, agent) under
key='agent_selection:<agent_id>':
disabled deny-lists per category — tools (whole or per tool),
MCP servers, named-service namespaces, skills
model the single pick from the admin's supported_models list
cache_policy the user's standing cold-cache policy per change class
pending one deferred change, promoted when its trigger fires
This is the record behind the chat composer's "+" menu: the menu reads the config-derived inventory plus this record in one round-trip, saves through debounced partial writes, and the next turn is simply built from the updated selection.
The record grew into a surface
The agent-selection record now reaches deeper and shows up in more places — same contract, three extensions:
- Namespaces narrow like tools. Deny keys reach INTO a service:
operations (
object.search) and named actions (object.action.send). A denied entry is not hidden UI — it is rejected at named-service dispatch for that user's turns, clamped on write to what the realm actually declares. - Services explain themselves. An expanded namespace renders as a service card built from the realm's own declaration: the purpose line, the third-party dependency ("Works with your Slack workspace through your connected Slack account"), and capability groups (Read / Create & update / Actions) summarized in human words ("Send email"), each entry's access line ("via your connected Google account · send mail") in the expandable details. A realm that declared nothing gets the honest "This service hasn't described itself yet."
- The picker became a surface of its own. One picker body, three
shells: the composer popover for quick toggles, a readable in-chat modal where the prose
wraps, and the served
capabilitieswidget — a full page mountable on any scene. All three write the same record through the same clamped merge-writes.
What belongs here — and what stays out
Durable per-user choices an app should honor on every turn belong here: toggles, picks,
standing policies, scope preferences. Two things stay out by rule:
per-conversation state (that lives with the conversation) and
secrets of any kind — tokens and credentials live in the user secret store
behind the connections stack, never in value_json.
One table, one contract, any number of stores: the admin grants the space, the user chooses within it, and every turn runs from the intersection.
Documentation on GitHub
The live docs behind this entry: