Skip to content

App surfaces

Declare a surface and the App Catalog renders it. There is no app-specific UI code anywhere in the platform, and there should be none in your app either.

On the facade, declaring a surface and implementing it are the same line of code:

app.param("dwell_s", float, default=30.0)     # a config-form field
app.zone("driveway")                          # a polygon the operator draws
app.metric("alerted", label="Alerts fired")   # a dashboard tile

@app.action("mute", label="Mute for an hour", confirm=True)
def mute():
    app.store["muted"] = True
    return {"muted": True}

@app.ui()
def dashboard() -> str:
    return f"<h3>{app.store['alerted']} alerts</h3>"

@app.on_license()
def check(key): ...                            # makes this a paid app

@app.on_config()
def changed(config): ...                       # applied without a restart

On a base class the same things are method overrides — state_snapshot, on_action, ui_html, verify_license, on_config_update — paired with manifest entries you write by hand. The rest of this page describes what each surface is; both paths reach it.

The config form

Every Param becomes a form field an operator fills in, and a key in config.yml. per_camera=True collects it once per camera; the type "geometry.polygon" becomes a zone editor drawn on a camera still.

Param("dwell_s", float, default=30.0, description="Seconds before it counts.")
Param("driveway", "geometry.polygon", per_camera=True)
Param("watch_labels", list, default=["person"], suggestions=list(DETECTION_LABELS[:8]))

Zones are one param each, named after the zone. A per-camera geometry.polygon value is {camera_id: [[x, y], …]} — one polygon per param, per camera, in normalized coordinates. So an app that watches a driveway and a kerb declares two params, and the operator sees two editors with those names. A single param called zones holding a mapping of names would not round-trip through the geometry editor and core's config validator rejects it. On the facade, zone="driveway" in a rule declares its param for you.

Config is live: core re-delivers it on a poll, so an app that applies changes in on_config_update follows the operator without a restart. Make that method idempotent — the first call usually restates what boot already applied.

The dashboard

state_schema declares how to render whatever state_snapshot() returns. Five kinds, all with a dot-path into that dict:

Kind Shows
metric one scalar as a stat chip
gauge a number between min and max, amber past warn, red past danger
table a list, with columns when the rows are dicts
log a recent-events feed, newest limit first
gallery thumbnails — plate crops, doorbell snapshots; data: URIs allowed

A missing path renders as an em-dash, never an error: /state is live data and may not have filled in yet.

For anything the five kinds cannot express, has_ui=True serves an HTML dashboard at GET /ui, proxied by core and rendered sandboxed. Use ui_mode="external" for an app that is a full application with its own web UI; the catalog then shows an "Open app" button instead of embedding.

Actions

An Action becomes a button with a generated form. The governance boundary is deliberate: core's proxy is user-JWT only, so actions are operator verbs and the OpenNVR Agent's service key can read your state but can never invoke one. confirm=True makes the catalog ask first.

Implement them in on_action, and respect its error contract: raise KeyError(name) for names you don't handle (→ 404), ValueError for bad params (→ 400). Anything else becomes a 500 without taking the app down.

current_user() inside an action returns the operator — a real UserContext with their per-camera permissions, so an action can scope what it touches to what that person may see.

The contract server

You never construct it; every archetype starts one when cfg.contract_port is set. What it serves:

GET  /health   /manifest   /state   /ui
GET  /openapi.json   /asyncapi.json
POST /actions/{name}   /entitlement/verify

Both specs are generated from the manifest.

Full example: 09_manifest_and_surfaces.py.