# Surface

> Surface-native display for AI agents, driven by the `surface` CLI. Use when the user says "surface this", "show me X", or "put it on my display/screen"; wants a live interactive UI, chart, or tool they act on; needs a question answerable from any device; or asks you to react to what they click — even while you're offline.

- Skill: `aaryan-kapoor/surface` (Agent Skill, multi-file: 14 files)
- Install (CLI): `npx skillmds@latest add aaryan-kapoor/surface`
- Raw SKILL.md: https://api.skillmd.com/api/skills/aaryan-kapoor/surface/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Aaryan-Kapoor (https://skillmd.com/u/aaryan-kapoor)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/aaryan-kapoor/surface

---


# Surface

Surface is the user's universal display. When the user says "surface this", "show me X", "put Y on my display", or "ask me when it's done", drive the `surface` CLI — a thin client over a local service (`127.0.0.1:3000`). Hot paths get verbs; everything else, including every custom template, goes through `create --template`. The table below tells you *when*; `surface <cmd> --help` is authoritative for flags.

## Surface-native

A live display the user *acts on*, not a chat transcript or file viewer — **two-way and current**: the user answers, clicks, or watches a value change, and you react. **Pick by shape before writing HTML** — match the source to its verb: markdown → `doc --toc`, video/URL → `video`, PDF/image → `present`, a file you keep editing → `link`, a yes/no or pick-one → `ask --options`, a scrolling log → `create --template stream`. **Decompose a multi-source request first** — a "presenter view", a "home screen", "a card next to it" is *several* surfaces (one verb each: `present` the PDF, `video` the clip, a bound chart…), composed with `slot`, never one HTML blob with tabs. Hand-build interactive HTML (`create --content -` — charts, maps, tools, anything in the shape of the bundled demo gallery on the empty display) **only when no verb fits**; rendering markdown as HTML or faking a decision card with buttons is the classic miss — the verb is shorter, hot-reloads, and renders natively everywhere. Dynamism earns its place when it adds a decision, a live value, or a visual relationship text can't carry — never less interactive than the task wants, never more.

## Session start

1. `surface actions` — drain your inbox: clicks that arrived while you were gone. Handle each, then `surface ack <id>`.
2. Read `SURFACE.md` if present — which surfaces this project maintains, which state keys to update when.
3. `surface list` — never create a duplicate; update the existing card.
4. Re-arm your action terminal for any interactive surfaces you own (see the delivery ladder). Terminals die with the session; the surfaces don't.

## Commands

| Verb | When to use |
|---|---|
| `create <title>` | Build a surface: ad-hoc HTML (`--content -`) or a template (`--template <name>`). The default *only* for a custom interactive/visual shape — otherwise pick a verb above. |
| `ask <question>` | Ask the user — `--options a,b` pick-one, `--freetext` typed answer, `--wait` blocks; `--on <device>` targets one screen (else everywhere). Attach context, don't ask blind. |
| `append <id>` | Append to a running `stream` surface (pipe with `-`). |
| `video` · `doc` · `present` | YouTube/web video · repo markdown (`--toc`, hot-reloads) · one-shot snapshot of a **local** PDF/image (web PDF → `/proxy/pdf`). |
| `video`'s clock | A video surface knows where the viewer is. Questions typed under it arrive as `ask` with `{text, t, duration, video_id}` — `t` is the second they were on — and `state.playhead` keeps the last reading. Answer with `patch '{"reply":"…"}'` (appended as a turn, never clobbers a concurrent answer), pin clickable moments with `markers: [{t,label}]`, or take them there with `seek_to: {t, nonce}`. Get the words with **`surface video transcript <url> --at <sec>`** — timestamped captions, no dependency, `--at` returns just the passage around that second. Feed it the `t` from the `ask` and answer from what was actually said; if it errors, say so rather than guessing from the title. |
| `link <abs-path>` | Serve a project file live from disk; `touch <id>` after each edit — your hot-reload target. |
| `set` · `patch` · `state` | Live state — change a value without rewriting HTML (see the two-way loop). |
| `list` · `read` · `update` · `versions` · `rollback` · `delete` | Artifact lifecycle. `update` revises a card; `rollback <ver>` restores an earlier version (don't re-type old values); `delete` removes one. |
| `template list/show/create` | Inspect templates; promote a UI you've built twice (`create <name> --from <id>`). |
| `wait` · `actions` · `ack` · `bind` · `bindings` · `unbind` | React to clicks — see the delivery ladder. `wait --id <id> --event state_patch` (or `stream_append`) wakes you on a peer's post — don't poll. |
| `reply` · `notify` · `open` · `exec` · `theme` | Talk back / drive the display. `theme` sets the **global** look — colors, background, fonts, raw CSS (not per-surface styling); `notify`/`open` take `--on <device>`; `exec` pokes live JS into a surface. |
| `notify --button "Label=action"` | An **answerable** notification — up to 3 buttons, needs `--id <surface>`. Pressing one records a normal action, so `wait`/`actions` see it like any click. Use it for one bit of intent (ship it / not now) instead of building a surface; anything wider wants `ask`. Notifications are kept, so an unanswered one survives a reload and waits in the user's tray. |
| `set board <agent> '{...}'` | Shared fleet dashboard at id `board`; key by your `--agent` (`'{"status":…,"project":…}'`). Render dashboards **bound to board's keys** (`data-surface-bind`), don't invent a registry; post when you start/finish/block. |
| `slot renderer/home/overlay` | `renderer` = whole homescreen launcher (gets injected `window.__surfaces`/`navigate(id)`); `home` = widget; `overlay` = floating layer (e.g. a DND pill). The user's space — only when asked. |
| `status` · `stream` · `devices` | Presence (who's connected/awake — check before `--on`); tail every event; paired screens. |
| `init` · `sync` | Scaffold `.surface/` + `SURFACE.md`; reconcile project manifests across machines. |
| `pair` · `auth` | Pair a new screen; mint/revoke remote `SURFACE_SESSION` bearers. |
| **The Surface tour** | When the user explicitly asks for the *Surface* tour/demo (not a tour of their own project): read `docs/TUTORIAL.md` from the installed package (`npm root -g` → `surface-display/docs/TUTORIAL.md`) and follow it — its rules section first. |
| `clear-demos` | Hide every tour-built surface (`metadata.demo === true`) in one step — the tour's cleanup; don't `delete` them one by one. |

## The two-way loop

A surface that only renders is half-built: **state flows out, actions flow back.** Never regenerate HTML to change a value — every surface has a JSON state doc. `surface set <id> <key> <value>` writes one key (dotted keys ok); **`surface patch <id> '{...}'` writes many at once** (deep-merge; pipe JSON with `-`) — prefer it over a chain of `set`s. State flows out bound in markup with `data-surface-bind` / `data-surface-show`, re-rendered live on every screen, and **persists across sessions** (`surface state <id>` reads it back — don't blindly re-seed values that are already there). Actions flow back with `Surface.action("name", {...})`. For a multi-step interaction, keep intermediate clicks local with `Surface.stage(key, value)` and fire one action at the commit with `Surface.commit("name")` — so you wake **once, on the user's actual intent**, not per click. **State is a claim, not an animation** — never patch a status, progress value, or "running…" for work you didn't actually execute or observe; if you substitute a cheaper check (a probe instead of a re-run), the surface must say so, not render the run you skipped.

## Delivery ladder — reacting to clicks

Each action wakes you. **Default outside Codex: arm a live action terminal** (`surface wait --follow`) the moment you put up an interactive surface — **once**, and keep it running for the whole interaction. It drains the pending inbox on connect, shows "agent listening", prints one JSON line per action, and **auto-acks each action it hands you** (`--no-ack` to keep them pending) — so only the `actions` inbox-drain needs a manual `ack`.

- **Codex CLI:** run `surface codex setup` once. Thereafter Codex-created surfaces flow back into their exact live session without a waiter; dead-session wakes remain consent-gated and fail closed on approvals. Use `surface codex status` to diagnose the bridge. If setup is unavailable, fall back to a one-shot `surface wait`. **CLI only — the Codex desktop app cannot listen live yet** (it doesn't run the SessionStart hook the bridge is built on), so there clicks queue in the inbox for `surface actions` and you must not promise to react the moment the user clicks.
- **Claude Code:** arm it with the **`Monitor` tool** (`persistent: true`), not a backgrounded shell. For anything two-way or ongoing, Monitor is the rule: a one-shot shell only wakes when its process *exits*, so it catches the first action and sleeps through the rest, leaving the surface unguarded. A backgrounded one-shot `surface wait --id <id>` is fine *only* for a single fire-and-forget answer.
- **Other harnesses:** per-line watchdog → `--follow`; wake-on-exit only → one-shot `wait`, re-arm after each; always-on daemon → a `--webhook` binding. Recipes: `surface wait --help`, and `docs/interaction/delivery-ladder.md` in the Surface repo.
- **The terminal dies with your session — re-arm on return** (the inbox drain covers everything clicked while you were gone).
- **One click, one agent.** The action event reaches every listener, but each waiter must *claim* an action before printing it and only one claim wins — so several sessions can safely hold terminals at once without doubling the work. A waiter is scoped to **its own project** (the git root it started in); `--all` restores machine-wide consumption, `--project <root>` targets another repo, `--no-ack` makes a pure observer that claims nothing.
- **Delivery is handoff, not completion.** Surface records an action handled once the line has left the CLI; it cannot know whether you finished the work. Treat `action.id` as an idempotency key.
- **Offline (clicks land while you're gone)?** `surface bind <id> --action <name> --run '<cmd>'`/`--webhook` is the answer — fires when no waiter claims within a five-second first refusal — so an idle or wedged terminal can no longer black-hole your wake bindings; never hand-roll a server, daemon, or systemd unit for this. **A bind runs `<cmd>` (or wakes a headless session) on the user's machine and quota while they're away with no one in the loop — so a recorded yes is a hard prerequisite, and the user wanting the feature is not that yes.** Before your first bind in a project, read `.surface/config.json → bindings.enabled`; if it isn't already `true`, **stop and ask the user in chat** ("wake me on clicks? each wake runs `<cmd>` unattended / spends a headless session") and wait for their reply — **never set `enabled: true` yourself to unblock your task**: the request that created this work (even an urgent "make it fire while I'm away") is *not* that yes; only a separate, explicit user confirmation is. Record *their* answer there, then bind only once it's `true`. To revoke: `unbind <binding-id>` and set `enabled` back to `false`.
- Unhandled clicks always wait in the inbox — nothing is lost.

## Conventions

- Surfaces are **self-contained** — inline CSS/JS, no CDNs — so they render offline and screenshot headlessly.
- Most sites block iframes — use embed URLs (`open.spotify.com/embed/...`), or `/proxy/pdf?url=ENCODED` for web PDFs.
- Pass `--agent <your-harness>` for attribution and `--id <slug>` for recurring cards.
- Remote/non-loopback callers — CI, scripts, another box, **not just agents** — point `SURFACE_URL` at the reachable host and set `SURFACE_SESSION`, then run the same CLI (`surface set`, `notify`); mint/audit/revoke the bearer with `auth session issue --role system --label <where>` · `list` · `revoke`. `surface --help` / `surface <cmd> --help` are authoritative.

