Surfaces - dynamically-definable chat modes & dashboards (design)
Status: SHIPPED (built 2026-06-20). The Surface system is live: model + repo + routers
(surfaces, admin_surfaces), agent/surface_resolver.py, the builtin seed
(db/seed/surface_seed.py), and the frontend (SurfaceLayout, surface-view-registry.ts,
lib/surfaces/strategy.ts, the editor). standard + coding ship as seeded builtin Surfaces; the
chat_modes table was dropped (chats.mode now resolves to a Surface). Two design points did NOT
ship as written: the Dashboard fold (§1, decision 3) was not done - Dashboard is still a
separate table/model/router; and sub-agent gating (decision 9) became DelegatableAgent.requires_surface
(a slug) + required_tools (capability keys), not a pure capabilities match. This doc keeps the
original design narrative below; concrete divergences are noted inline.
Goal
Today a chat’s mode is a hardcoded String(64) enum: "standard" | "coding" | <ChatMode-uuid>.
"coding" is wired by hand (workspace device, jailed FS, CODING_BEHAVIOR instructions, AGENTS.md
injection, git shadow, model boost, sub-agent gating, Monaco+terminal panels). A dynamic ChatMode
row exists but can do only one thing: show a dashboard of cards in a side panel.
We want chat modes to be dynamically definable like sub-agents (DelegatableAgent): the default
is a plain chat, but any mode can add sections — an editor, a terminal, Home-Assistant-style
cards — and declare what it needs (e.g. a workspace). A chat session becomes a mix of an LLM chat
and other sections. Crucially, the same definition must power a pure dashboard with no chat at
all. And what are side-by-side sections on a large screen must become bottom tabs on a small
screen (as coding mode already does: chat | editor | terminal | agents).
We orient strongly on Home Assistant Lovelace (cloned at /root/references/ha-core +
/root/references/ha-frontend, refreshed 2026-06-20). Lovelace is a battle-tested, config-driven,
pluggable layout system — we adopt its model almost wholesale.
The one idea: a Surface
A Surface is a Lovelace-style, dynamically-definable layout. A chat mode is a Surface that contains a
chatview; a pure dashboard is the same Surface without achatview.
Hard rule: no code branches on a mode/surface id
Nothing in the code may key off the string "coding" (or any specific surface id). “Coding” is a
seeded Surface row, identical in kind to a user-defined one — builtin=true only marks it as
shipped/seed-managed, it grants no special code path. Every behavior that is hardcoded today
(workspace, jailed FS, git shadow, AGENTS.md project rules, the coding instructions, the model boost,
sub-agent gating) must derive only from a Surface’s declared properties:
- which views it has (
editor/terminal⇒needs_workspace), - its
chat.agentoverlay (instructions,tool_config,model_tags), - its
agent.capabilitieskeys (what enables jailing / git-shadow / project-rules / sub-agent gating).
Consequence: a user can build their own coding-like Surface; the shipped one is just the default seed.
If we ever find ourselves writing if surface.slug == …, the design has leaked — push it into a
declared property instead.
This unifies three things that are separate today into one concept:
| today | becomes |
|---|---|
hardcoded "standard" mode |
a seeded builtin Surface = one chat view |
hardcoded "coding" mode |
a seeded builtin Surface = chat + workspace views, needs_workspace (shipped as ONE combined workspace view, not separate editor+terminal) |
dynamic ChatMode (cards side-panel) |
a user Surface = chat + cards view |
a standalone Dashboard |
a Surface with no chat view |
Region = View (the HA mapping)
Lovelace already models a dashboard as Dashboard → Views → Sections → Cards and renders the
views as a tab bar. Our “sections/regions” map exactly onto Lovelace Views:
- Large screen → the Surface’s views are laid out side-by-side (a splitter/grid), ordered + weighted. (Chat is just one view.)
- Small screen → the same view list becomes bottom tabs (one per view). This generalizes
today’s hardcoded
CODING_VIEWS/CARDS_VIEWS/STANDARD_VIEWSinto one derived list.
So a Surface’s views[] is the single source that drives both layouts. No per-mode special cases.
New view/card types (the only real additions to the Lovelace model)
Lovelace views/cards are all data widgets. We add three stateful panel types, registered through
the same create-element factory + registry HA uses (hui-${type}-${suffix}, always-loaded vs
lazy, custom: prefix for plugins):
chat— the LLM conversation. Carries the agent overlay (see §4). At most one per Surface; its presence is what makes the Surface a “mode” vs a “dashboard”.editor— Monaco + file tree over a workspace (reusesWorkspacePanel+fs_*RPC).terminal— xterm/PTY over a workspace (reusesDeviceTerminal+TerminalHub).
cards views (sections of HA cards) stay 100% the existing system — CardRenderer,
card-registry.ts, the 60 card types, the 5 view layouts (sections/masonry/panel/sidebar),
the 12-col grid + grid_options, conditions, actions, useLiveEntity. Untouched.
1. Data model (HA two-layer + DelegatableAgent scoping)
HA splits a lightweight dashboards metadata collection (url_path, title, icon,
require_admin, show_in_sidebar, mode) from the heavy config blob ({views} or {strategy}).
We mirror that, and add DelegatableAgent-style scoping (scope global/user, owner_sub, org_id
RLS, enabled, builtin, auto-slug):
class Surface(UUIDPKMixin, TimestampMixin, Base): # metadata layer
name, slug, icon, description
scope, owner_sub, org_id, enabled, builtin # exactly like DelegatableAgent
kind: str # "mode" | "dashboard" (derived: has a chat view? → mode)
show_in_nav: bool # appears as a dashboard/drawer entry (HA show_in_sidebar)
require_admin: bool
config: JSONB # the Lovelace-style blob (the heavy layer) — see §2
ChatModeis replaced bySurface(thechat_modestable was dropped). Shipped.Dashboardis replaced bySurfacewithkind="dashboard"(itsconfigalready is a Lovelace{views}blob - drop-in). Standalone dashboard pages render a chatless Surface. NOT shipped:Dashboardremains its own table/model/router (db/models/dashboard.py,api/routers/dashboards.py); the fold (decision 3 / rollout §9.6) was deferred.chats.modekeeps the string and resolves to a Surface (surface_resolver.resolve_chat_surface:None/"standard"→ standard builtin,"coding"→ coding builtin, else a slug/uuid)."standard"/"coding"are seeded builtin Surfaces. Shipped.
2. Config schema (Lovelace-compatible)
The config blob is a Lovelace dashboard config, extended with an arrangement for the
side-by-side desktop layout and our new view types:
{
"title": "Coding",
"arrangement": { // desktop side-by-side; mobile → bottom tabs (order = tab order)
"layout": "split", // "split" | "single" | "sidebar"
"items": [
{ "view": 0, "weight": 55 }, // chat
{ "view": 1, "weight": 45, "group": "right" } // editor+terminal share the right group (tabs)
]
},
"views": [
{ "type": "chat", "icon": "chat", "title": "Chat",
"agent": { /* §4 */ } },
{ "type": "editor", "icon": "code", "title": "Editor", "workspace": "default" },
{ "type": "terminal", "icon": "terminal", "title": "Terminal", "workspace": "default" },
{ "type": "sections", "icon": "dashboard","title": "Cards",
"sections": [ { "type": "grid", "cards": [ /* existing HA cards */ ] } ] }
]
}
- A pure dashboard = the same shape with no
chatview (often a singlesectionsview,arrangement.layout="single"). views[]is reused verbatim from Lovelace forsections|masonry|panel|sidebar;chat|editor| terminalare newtypes dispatched by the same factory.strategy(HA’s generated-layout key) is allowed at dashboard AND view level (see §5).
3. Responsive: one list → desktop regions / mobile tabs
arrangement.items (referencing views[]) is the single source:
- Desktop: render the items as splitter panes (by
weight), items in the samegroupstack as tabs within one pane (e.g. editor+terminal on the right). - Mobile (
$q.screen.lt.md): render the views as bottom tabs (icon+title), one per view, inarrangementorder — exactly today’s behavior, now derived not hardcoded.
A SurfaceLayout component owns this; the per-view renderers are chat / editor(WorkspacePanel)
/ terminal(DeviceTerminal) / cards(the existing ViewRenderer).
4. The agent overlay (the chat view’s config) — mirrors DelegatableAgent
When a Surface has a chat view, that view’s agent block carries the per-mode agent behavior —
the dynamic replacement for the hardcoded coding branches:
"agent": {
"instructions": "…", // overlay onto base instructions (coding mode seeds CODING_BEHAVIOR)
"tool_config": { … }, // overlay onto run_config tool keys (like DelegatableAgent.tool_config)
"model_tags": ["coding"], // bias auto-model (replaces the hardcoded coding boost)
"security_default": "judge", // optional per-mode default
"memory_default": { … }, // optional per-mode default
"capabilities": ["coding"] // keys sub-agents gate against (see §6)
}
This is the exact DelegatableAgent shape (instructions + tool_config + model selection +
gating), just attached to a Surface’s chat view instead of a delegatable agent.
5. Strategies (HA’s killer feature — adopt it)
HA lets a dashboard/view/section be generated instead of static: { "strategy": { "type": … } }.
A strategy is a client-side generator generate(config, hass) → expanded config, lazy-loaded by
type, with custom: plugins, and shouldRegenerate(old, new) to refresh on state change.
We adopt it directly. It unlocks dynamic, generated Surfaces:
original-states-style: build acardsview from the user’s entities automatically.agentstrategy: ask the agent to generate the dashboard layout (the LLM emits the views/ cards JSON) — a natural fit for this product.- per-view strategies inside an otherwise static mode.
Our generate() runs against our entities/integration registry (the analog of hass). Build the
recursive expandSurfaceStrategies(config) exactly like expandLovelaceConfigStrategies.
6. Backend: definition-driven instead of if mode == "coding"
Every hardcoded coding branch is re-pointed at the Surface definition (same machinery, new trigger):
| today (hardcoded) | becomes |
|---|---|
mode=="coding" → implicit workspace device, jailed FS |
Surface has an editor/terminal view ⇒ needs_workspace |
CODING_BEHAVIOR injected |
chat.agent.instructions (coding Surface seeds it) |
| auto-model coding boost | chat.agent.model_tags |
DelegatableAgent.requires_mode=="coding" |
DelegatableAgent.requires_surface (a slug) + required_tools (capability keys); the worker checks both |
chat_workspace_id/path gated on "coding" |
gated on needs_workspace |
| HOME file search only in standard | gated on absence of a workspace |
| git shadow snapshot (run reversion) | implied by needs_workspace (any workspace surface gets it) |
| AGENTS.md / CLAUDE.md project-rules injection | implied by needs_workspace (or an explicit project_rules capability) |
| jail FS to the workspace folder | implied by needs_workspace |
| cloud-sandbox snapshot restore | implied by needs_workspace + a managed compute device |
The remaining “heavy coding machinery” (git shadow, project-rules, jailing, sandbox restore) is
workspace behavior, not “coding” behavior — so it keys off needs_workspace (a derived flag),
never off a surface id. If finer control is wanted, expose them as named capabilities the Surface
opts into; the seeded coding Surface simply declares them.
needs_workspace is derived (any editor/terminal view). Workspace stays per-chat
(run_config.workspace_device_id/path); the Surface only declares the need, which triggers the
existing CodingWorkspaceSetup flow (Ort local/cloud + Projekt existing/new/git).
7. Edit mode (reuse + align to HA)
Adopt HA’s editor model, which our dashboard editor already approximates: a editMode flag, GUI vs
YAML/JSON card editors (getConfigElement / getConfigForm on each card type), immutable
config-util (addCard/replaceCard/deleteCard/addView…), undo via config snapshots, drag/resize
in the grid. The Surface editor is the dashboard editor + view-add for chat/editor/terminal.
8. Card / view contract (for pluggability)
Mirror HA’s LovelaceCard contract so every view/card is uniform + pluggable:
setConfig(config), getCardSize(), optional getGridOptions(), static getStubConfig() /
getConfigElement(). New view types implement a thin SurfaceView contract (setConfig, declares
needs_workspace, optional mobileTab). custom:-prefixed types resolve from a registry → enables
plugin cards/views/strategies later (HA’s resources collection; defer to a later increment).
!!! note “Shipped extension: integration-contributed Surfaces”
Beyond user/global Surfaces, an integration can declare surfaces() (a SurfaceDescriptor
map). integrations/contrib.py:_project_surfaces upserts these as Surface rows tagged with
source_domain=<domain>; they are read-only (re-projected on reconfigure) and their lifecycle
follows the config entry, mirroring integration-contributed agents. The Surface.source_domain
column backs this.
9. Incremental rollout (coding stays working throughout)
Surfacemodel + repo + router (user/admin) per the DelegatableAgent pattern; store + Settings form (name/icon/scope + view list editor).SurfaceLayout(desktop split + mobile tabs fromarrangement) + thechat/editor/terminalview types in thecreate-elementregistry;cardsview = existingViewRenderer. Lay the strategy plumbing here too (expandSurfaceStrategies+ a strategy registry), even though the first strategies (agent-generated layout) come right after — cheap to add while the structure is new.- Seed
standard+codingas builtin Surfaces;chats.moderesolves a Surface. Delete the hardcodedCHAT_MODESregistry + the 3 mobile-tab variants. - Re-point backend branches (
mode=="coding"→needs_workspace/capabilities; agent overlay from the Surface). - Migrate
ChatMode→ Surface; replaceDashboardModePanelwith thecardsview. - Fold
Dashboard→ Surface (kind="dashboard"); standalone dashboard pages render a chatless Surface. (The clean-break step.) NOT done:Dashboardis still its own table/router. - Strategies (§5) + plugin resources (§8) as follow-ups. (Strategy plumbing shipped as a
passthrough in
lib/surfaces/strategy.ts; the agent-strategy generator is still a follow-up.)
10. Decisions
Locked (2026-06-20):
- ✅ Region = View (HA-native: views → desktop split / mobile tabs).
- ✅ chat/editor/terminal = new VIEW types (full stateful panels; cards stay data widgets).
- ✅ Fold fully: Dashboard + ChatMode + coding → ONE
Surface. Standalone dashboards = chatless Surfaces. (Biggest step - sequenced last in rollout to keep things shippable.) As shipped: ChatMode and coding folded in; the standaloneDashboardtable/router was NOT folded - still separate. - ✅ Strategies plumbing now, the agent-strategy (LLM builds the layout) as a fast follow. (Plumbing shipped passthrough; agent-strategy not yet built.)
- ✅ No id-branching:
"coding"is a seeded Surface row like any other; all behavior derives from declared views / agent overlay / capabilities. Noif surface.slug == …anywhere. Shipped: the resolver still special-cases the strings"standard"/"coding"only to map them to builtin slugs, not to branch behavior.
Defaults (proceed unless objected):
6. Scope = user + global like DelegatableAgent (+ org/group). Shipped (scope global/user, RLS).
7. Storage = extend the existing dashboards table into surfaces (+ migrate ChatMode).
As shipped: surfaces is a NEW table (surfaces_01); dashboards was left in place, not extended.
8. Workspace stays per-chat (run_config); the Surface only declares needs_workspace. Shipped.
9. Sub-agent gating switches DelegatableAgent.requires_mode (string) → match Surface
agent.capabilities keys. As shipped: it became DelegatableAgent.requires_surface (a slug) +
required_tools (capability keys); the worker checks both.
Still open:
10. Custom plugin cards/views/strategies (HA resources) — in scope eventually, or never
(untrusted JS = security surface)? Deferred regardless.