Skip to content

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, in arrangement order.

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> (via surface_slugify);
  • scope follows the entry (a user-scoped entry → user surfaces, a global/org entry → globals);
  • kind is derived from the config (surface_kind);
  • the row is stamped with source_domain = <domain> and builtin = 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 — one sections view, 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