Authoring surfaces & cards¶
A Surface is a single, Lovelace-inspired declarative layout that powers two things that
used to be separate: a chat mode and a dashboard. A chat mode is just a Surface that
contains a chat view; a pure dashboard is the same shape without a chat view. This page is
the developer reference for the data model, the config blob, how integrations contribute
surfaces, and the card model underneath it all.
The full design rationale lives in docs/design/surfaces.md. The hard
rule from that doc is worth repeating up front, because it shapes everything else:
No code branches on a surface id
Nothing in the code may key off a specific slug ("coding", "standard", …). The built-in
surfaces are seeded rows, identical in kind to a user-defined one — builtin=true only marks
them as code-owned/seed-managed and grants no special code path. Every behavior derives from
a surface's declared config: which views it has, the chat view's agent overlay, and the
declared capabilities. If you find yourself writing if surface.slug == …, push it into a
declared property instead.
The Surface model¶
A Surface mirrors the HA two-layer split: a lightweight metadata layer plus a heavy
config blob. The columns (services/api/src/personal_agent/db/models/surface.py) are:
| Field | Type | Meaning |
|---|---|---|
name |
String(128) |
Display name shown when picking a mode / listing dashboards. |
slug |
String(64) |
Stable handle (<scope>+<owner>+slug unique; global slugs unique via a partial index). |
icon |
String(64)? |
Material icon name. |
description |
Text |
Shown to the user; default "". |
kind |
String(16) |
"mode" or "dashboard" — derived from the config, stored for cheap filtering. |
config |
JSONB |
The Lovelace-style layout blob ({views, arrangement} or {strategy}). The heavy layer. |
show_in_nav |
bool |
Listed as a drawer/dashboard entry (HA show_in_sidebar); mostly for dashboards. |
require_admin |
bool |
Admin-only visibility. |
builtin |
bool |
Seeded, code-owned (re-synced on boot). Read-only name/config for non-admins. No special code path. |
source_domain |
String(128)? |
Set when projected from an integration's surfaces() (the integration domain). Read-only; lifecycle follows the config entry. |
scope |
String(16) |
"global" (admin-managed, everyone) or "user" (self-service, owner-only). |
owner_sub |
FK users.sub |
Non-null for user surfaces; null for globals. |
org_id |
FK organizations.id |
Tenant scoping (RLS via tenant_isolation). |
enabled |
bool |
Soft-disable without deleting. |
Scoping mirrors DelegatableAgent exactly. kind is never set by hand — it is computed by
surface_kind(config), which returns KIND_MODE iff any view has type == "chat", otherwise
KIND_DASHBOARD.
from personal_agent.db.models.surface import surface_kind, KIND_MODE, KIND_DASHBOARD
surface_kind({"views": [{"type": "chat"}]}) # "mode"
surface_kind({"views": [{"type": "sections"}]}) # "dashboard"
SurfaceRepo (db/repositories/surface_repo.py) is the access layer. Note one deliberate
difference from delegatable agents: list_visible includes builtins — a builtin surface like
coding is a normal selectable option, not a hidden default. get_by_slug resolves a slug for a
principal with the user's own surface winning over a global of the same slug.
The config blob¶
config is a Lovelace dashboard config, extended with an arrangement for the side-by-side
desktop layout and the new stateful view types. The frontend shape lives in
apps/web/src/stores/surfaces.ts:
{
"views": [
{ "type": "chat", "icon": "chat", "title": "Chat", "agent": { /* overlay */ } },
{ "type": "workspace", "icon": "code", "title": "Workspace" },
{ "type": "cards", "icon": "dashboard", "title": "Cards",
"sections": [ { "type": "grid", "cards": [ /* HA cards */ ] } ] }
],
"arrangement": {
"layout": "split", // "split" | "single"
"items": [
{ "view": 0, "weight": 55 }, // index into views[], weight, optional group
{ "view": 1, "weight": 45 }
]
}
}
A pure dashboard is the same shape with no chat view (often a single cards view and
arrangement.layout: "single"). A strategy-driven surface replaces views with a
strategy block (see Strategies).
Region / view types¶
views[] is the single source that drives both layouts. The renderer maps each view's type to
a component via the surface-view registry
(apps/web/src/components/surfaces/surface-view-registry.ts), mirroring HA's create-element
factory:
type |
Renderer | Notes |
|---|---|---|
chat |
(chat panel, inside a chat context) | The LLM conversation. At most one per surface; its presence makes the surface a mode. Carries the agent overlay. |
cards / sections / grid / masonry |
CardsSurfaceView |
A region of HA cards. Renders standalone (no chat needed). These are the STANDALONE_VIEW_TYPES. |
editor / terminal / workspace |
WorkspaceSurfaceView |
Monaco / PTY / the combined WorkspacePanel. Real panel inside a chat (chatContext); a placeholder otherwise. |
| anything else | PlaceholderSurfaceView |
Fallback for unknown types and for chat/editor/terminal rendered standalone. |
Domain widgets are cards, not view types
Anything domain-specific (a TradingView chart, a vendor widget) is a card inside a cards
region, contributed through the card/integration system — never a bespoke hardcoded view type.
The registry intentionally holds only the core region types.
The three workspace view types (editor, terminal, workspace) are special: they imply a
per-chat workspace. The backend resolver treats {"editor", "terminal", "workspace"} as
_WORKSPACE_VIEW_TYPES.
Arrangement → desktop split / mobile tabs¶
SurfaceLayout.vue turns the config into one ordered list of regions that drives both
layouts:
- Desktop: regions render side-by-side as weighted flex panes (
flex: <weight> 1 0), with vertical separators between them. - Mobile (
$q.screen.lt.md): the same list becomes bottom tabs, one per region, inarrangementorder.
If arrangement.items is present it sets order + weights (each item's view is an index into
config.views); otherwise the layout falls back to views[] in order with equal weight. There
is no per-mode special-casing — coding's chat | workspace split and a dashboard's single
cards view go through the exact same code.
The chat view's agent overlay¶
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. The backend reads it through
agent/surface_resolver.py:
"agent": {
"instructions": "…", // surface_instructions()
"model_tags": ["coding"], // surface_model_tags() — biases auto-model
"capabilities": ["coding", "workspace"] // surface_capabilities()
}
| Helper | Returns |
|---|---|
surface_chat_agent(surface) |
The chat view's agent dict, or {}. |
surface_instructions(surface) |
agent.instructions as a string. |
surface_model_tags(surface) |
agent.model_tags as a list of strings. |
surface_capabilities(surface) |
agent.capabilities as a set; workspace is added automatically if any editor/terminal/workspace view is present. |
surface_needs_workspace(surface) |
True iff a workspace view exists or the workspace capability is declared. |
Every helper accepts either a Surface (reads .config) or a plain config dict, so a
caller can transparently pass a per-chat override.
Resolving a chat's surface (+ per-chat override)¶
resolve_chat_surface(session, chat) maps chat.mode to a Surface:
None/"standard"→ the standard builtin (by slug)"coding"→ the coding builtin (by slug)- a UUID string → a Surface by id
- anything else → a Surface by slug (the user's own wins over a global)
effective_surface_config(chat, surface) picks the config to actually use: a per-chat
chat.run_config["surface_config_override"] (a full config the user extended live in that chat,
with non-empty views) wins; otherwise the shared surface's config. This lets one chat extend its
own layout without mutating the shared Surface or affecting other chats.
Built-in surfaces (seed)¶
standard and coding ship as global, builtin=True surfaces seeded as data
(db/seed/surface_seed.py). They are code-owned: their config/name/icon are re-synced
from the code on every boot (seed_builtin_surfaces), while id/slug/enabled/scope are
left alone. An admin who wants a tweaked coding mode should clone it into a user/global
surface rather than edit the builtin — the re-sync would overwrite an edit.
The coding builtin is the canonical example of "behavior as data": its chat view's agent
overlay carries CODING_BEHAVIOR instructions, model_tags: ["coding"], and
capabilities: ["coding", "workspace"], and it has a workspace view. Everything that used to
be if mode == "coding" derives from those declarations.
Integration-contributed surfaces¶
An integration contributes surfaces the same way it contributes delegatable agents: it overrides
surfaces() to return a map of stable keys → SurfaceDescriptor
(services/api/src/personal_agent/integrations/integration.py):
@dataclass(frozen=True)
class SurfaceDescriptor:
name: str
config: dict[str, Any] # the Lovelace-style {views, arrangement} blob
icon: str | None = None
description: str = ""
show_in_nav: bool = False
On config-entry setup, _project_surfaces (integrations/contrib.py) projects each descriptor
into the surfaces table:
- the slug becomes
<domain>-<key>(viasurface_slugify); scopefollows the entry (a user-scoped entry → user surfaces, a global/org entry → globals);kindis derived from the config (surface_kind);- the row is stamped with
source_domain = <domain>andbuiltin = False.
Projected rows are read-only (re-projected on reconfigure) and their lifecycle follows the config entry: created on setup, removed when the last entry of the domain goes. A slug collision with a row owned by a different domain is logged and skipped.
Example: the TradingView surface¶
integrations/tradingview/__init__.py is the reference second example (after coding). It
contributes a surface that is chat beside a live chart, with the chart rendered through the
generic iframe card inside a cards region — not a bespoke view type:
def surfaces(self) -> dict[str, SurfaceDescriptor]:
return {
"trading": SurfaceDescriptor(
name="Trading",
icon="candlestick_chart",
description="Chat beside a live TradingView chart.",
config={
"views": [
{"type": "chat", "icon": "chat", "title": "Chat",
"agent": {"instructions": _TRADING_BEHAVIOR, "capabilities": ["trading"]}},
{"type": "cards", "icon": "candlestick_chart", "title": "Chart",
"cards": [{"type": "iframe", "url": _TV_EMBED, "height": 760, "w": 12, "h": 20}]},
],
"arrangement": {"layout": "split",
"items": [{"view": 0, "weight": 55}, {"view": 1, "weight": 45}]},
},
)
}
The integration also contributes a delegatable agent gated to the surface via
requires_surface="tradingview-trading" (matched against the projected slug).
The card model¶
A cards region holds Home Assistant Lovelace cards — a faithful port unchanged by surfaces. The
config shapes are in apps/web/src/lib/dashboard/types.ts; the type → component map, picker
metadata, config schemas and stub configs are in
apps/web/src/components/dashboard/card-registry.ts.
Adding a card type¶
Register the card in all four maps in card-registry.ts:
| Map | Purpose |
|---|---|
cardRegistry |
type → the (async) Vue component. |
CARD_META |
Picker { label, icon, description }. |
cardSchemas |
The config-form fields (CardField[]: text/textarea/number/entity/json/area/device/scene/action/features). |
stubConfig(type) |
The default config + grid size for a freshly-added card. |
For the sections layout, also give the card sensible defaults in CARD_GRID_DEFAULTS (a 12-column
grid, 56px rows); gridOptionsFor(card) merges those defaults with the card's explicit
grid_options.
The card families currently registered include data widgets (markdown, heading, entity,
entities, glance, sensor, gauge, history, calendar, clock, weather-forecast,
map, picture cards…), interactive entity controls (tile, toggle, number, select,
text, counter, datetime, light, thermostat, alarm-panel, media-control…), registry
cards (area, device), and container/conditional cards (vertical-stack, horizontal-stack,
grid, conditional).
Conditions (visibility)¶
Every DashboardCard, BadgeConfig and SectionConfig may carry a visibility: Condition[].
The engine is apps/web/src/lib/dashboard/conditions.ts, a port of HA's condition validator. The
top level is an AND (all must hold). Supported conditions:
condition |
Fields | Holds when |
|---|---|---|
state |
entity, attribute?, state / state_not |
The entity (or attribute) state is in / not in the listed value(s). |
numeric_state |
entity, attribute?, above?, below? |
The numeric state is within the bounds. |
screen |
media_query |
window.matchMedia(query).matches. |
time |
after?, before?, weekdays? |
Current time is in range (handles crossing midnight) and weekday matches. |
and / or / not |
conditions[] |
Boolean composition of nested conditions. |
A legacy shorthand ({entity, state, state_not} without a condition: key) is also accepted. Use
checkConditionsMet(conditions, env) to evaluate against a ConditionEnv (which supplies the
entity lookup); extractConditionEntityIds / extractConditionMediaQueries tell you what to
subscribe to for live updates. CardWrapper.vue wires conditions to rendering: in view mode a
card whose conditions are unmet is hidden; in edit mode it is shown dimmed with an "hidden by
condition" chip.
Tap / hold actions¶
Interactive cards (e.g. tile, button, badges) take ActionConfig slots — typically
tap_action, hold_action, double_tap_action, icon_tap_action. The shape
(lib/dashboard/types.ts):
action |
Extra fields | Effect |
|---|---|---|
more-info |
entity? |
Open the entity detail dialog. |
toggle |
— | Toggle the bound entity. |
navigate |
navigation_path |
Navigate within the app. |
url |
url_path |
Open an external URL. |
perform-action |
perform_action, data?, entity? |
Call a named entity action with a payload. |
assist |
— | Open the main chat (our equivalent of HA's voice assistant). |
none |
— | No-op. |
Any action may carry confirmation (a boolean or { text? }) to require a confirm step.
Strategies (generated layouts)¶
A dashboard, view, or section can be generated instead of static via a strategy block
(config.strategy = { type, … }). apps/web/src/lib/dashboard/strategies.ts ports HA's
original-states and areas generators against Personal Agent entities/areas:
original-states— onesectionsview, one section per entity domain.areas— a "Bereiche" overview view plus one subview per area.
generateStrategyViews(strategy, ctx) runs against a StrategyContext ({ entities, areas }) —
the analog of HA's hass. materializeStrategy(config, ctx) "takes control" by freezing the
generated views into the stored config so the user can hand-edit it; isStrategyConfig(config)
reports whether a config is strategy-driven.
Edit mode¶
The surface editor reuses the dashboard editor. SurfaceLayout.vue accepts an editing flag and
a SurfaceEditController (edit) that owns structural region ops (addRegion, removeRegion,
moveRegion, setWeight). Card-level edit ops (editCard, duplicateCard, moveCardDialog,
copyCard, cutCard, removeCard) come from the dashboard edit API surfaced through
CardWrapper.vue. The host (page) owns undo/redo and save — the layout only emits structural
changes.
Quick reference¶
| Concern | Where |
|---|---|
| Surface model | services/api/src/personal_agent/db/models/surface.py |
| Surface repo | services/api/src/personal_agent/db/repositories/surface_repo.py |
| Surface resolver + overlay helpers | services/api/src/personal_agent/agent/surface_resolver.py |
| Builtin seed | services/api/src/personal_agent/db/seed/surface_seed.py |
| Integration contribution | integrations/integration.py (SurfaceDescriptor, surfaces()), integrations/contrib.py (_project_surfaces) |
| Layout / regions | apps/web/src/components/surfaces/SurfaceLayout.vue, surface-view-registry.ts |
| Frontend types | apps/web/src/stores/surfaces.ts, apps/web/src/lib/dashboard/types.ts |
| Card registry | apps/web/src/components/dashboard/card-registry.ts |
| Conditions / strategies | apps/web/src/lib/dashboard/conditions.ts, strategies.ts |
| Design doc | docs/design/surfaces.md |