Create, register, complete, or repair an AI Matrx agent-aware UI surface end to end: manifest identity, values, groups, inheritance, write targets, roles, DB mirror, live scope, canonical v3 context menus, Pro inputs, bindings, and verification. Use for new surfaces and for existing surfaces that are partial, stubbed, unregistered, missing runtime context, missing canonical menus, or not fully agent-wired. NOT for context-menu primitive internals or binding-service internals.
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
Instruções da origem · Visualização somente leitura
name
surface-authoring
description
Create, register, complete, or repair an AI Matrx agent-aware UI surface end to end: manifest identity, values, groups, inheritance, write targets, roles, DB mirror, live scope, canonical v3 context menus, Pro inputs, bindings, and verification. Use for new surfaces and for existing surfaces that are partial, stubbed, unregistered, missing runtime context, missing canonical menus, or not fully agent-wired. NOT for context-menu primitive internals or binding-service internals.
Surface authoring
This is the ONE surface lifecycle skill. It owns the manifest contract, layered registration, runtime emission, canonical-menu rollout, Pro inputs, bindings, DB sync, and verification. Reference consumer for every registration layer: features/transcription-cleanup/ (/transcripts/cleanup).
Choose the path
Agent-native surface: stop before the binding lifecycle. Chat, Agents Hub,
Agent Apps, Agent Build/Run/Battle, mandate authoring, agent comparison/history,
and agent/widget test harnesses are subjects or universal hosts. They may keep
neutral runtime identity for menus, but get no roles, defaults, bindings,
bound roster, Bind control, or disclosure UI.
New or structurally changed surface: follow this file from identity through layered registration.
Existing surface that needs completion or repair: read references/runtime-rollout.md, then close every applicable contract here.
Context-menu wiring or repair: invoke context-menu-v3; its skill owns wrapper choice, per-row delegation, contentSource, entity, and no-fake-menu proof. This skill owns making that canonical menu part of a complete surface.
Full certification: invoke surface-check; it drives the S1–S18 checklist and ledger.
Adding a surface is code-first, DB-mirror. Code is the single source of truth — the DB is a synced reflection. Get the manifest right and everything downstream (binding UIs, chrome labels, drift report, RLS-gated agent + tool bindings, the runtime resolver) just works.
What a surface is — and the recursion that trips people up
An eligible surface exists to bind highly custom agents to a specific place and hand them highly specific context. Agent-native hosts are the boundary: when the agent is what the UI is for, it is the subject, not a binding target.
A context item in one surface can itself BE a surface — and then its context is its own parts, not itself. This is the model that confuses people:
In the chat surface, the working document and the scratchpad are context items — whole values handed to the conversation's agent.
Step inside one and it stops being a context item. It becomes its own surface, and its context items are its parts: the body text, the selection, the id, the title. You would never attach the whole document as its own surface's context — that's circular. The parts are the context.
Zoom back out from inside: the conversation the document hangs off is not its context either — it's a reference (conversation_id) plus whatever the host chooses to pass through the link (conversation_context). A reference, not an embed.
Purpose flips when you step inside. Outside, the scratchpad is "the user's private notes the cloud agent only reads." Inside, it is just text — a context-menu agent there can absolutely edit it (bullet it, tabulate it, clean it up). The read-only-ness was a fact of the outer surface, not an intrinsic property of the text.
Boundaries are a perspective you choose, then commit to. The chat sidebar (list of chats + agents) and the open chat (one conversation_id) can be modeled as two surfaces (a list surface + a single-conversation surface) or one (a chat surface with an active conversation and a list of the others). Both are valid. Pick the framing that matches how agents will be bound, then design to it.
Same shape ≠ same surface. Two surfaces can share an identical value set and still be two surfaces when the purpose — and therefore the bound agents — differ. matrx-user/working-document and matrx-user/scratchpad share one value set (_conversation-document.manifest.ts) but stay separate because a co-author agent belongs on one and not the other. Conversely, only merge kinds into one surface when the values AND the relevant agents are ~identical.
THE NAMING LAW — one canonical label, everywhere
SurfaceManifest.label is REQUIRED. It is the ONE canonical human display name for the surface — unique per client (case-insensitive; pnpm check:surface-drift fails on a missing or clashing label). Every value's label and every group's label is equally canonical.
No chrome may hand-type, override, or re-derive a surface/value/group label. The surfaceLabel runtime override prop was DELETED; ESLint bans it (surfaceLabelOverrideBan in eslint.config.mjs).
Chrome derives the surface name via getSurfaceDisplayLabel(surfaceName) from features/surfaces/utils/surface-display.ts (static + synchronous; safe in server and client components). labelFromName slug fallback is for manifest-less DB surfaces ONLY.
On-page section titles and field labels for declared values render via surfaceValueLabels(manifest) / surfaceGroupLabels(manifest) (same file) — byte-identical to the manifest. A page that hand-writes "Page intent" next to values grouped under page_intent is a defect; render G.page_intent.
label is mirrored to ui_surface.label by manifest sync (ALWAYS written); DB drift shows in the drift report as surfaceLabelDrifts.
Labels never enter agent feeds. aidream's manifest feed carries machine names + group_key only — agents see name, humans see label.
THE COMPLETENESS LAW — declare everything the page loads
Every piece of data/state a page loads MUST be declared as a surface value. Individual fields AND their natural composite/group values are both mandatory — e.g. marketing-page declares the composite page_intent object alongside its four constituent fields. Optional convenience packs are the only discretionary part.
Undeclared runtime keys show loudly in the Surface Context window under "Undeclared (runtime only)" — every entry there is a defect: either declare the value or stop emitting it. Use autoContext: false to keep declared-but-rarely-needed values out of automatic agent context; never use non-declaration for that.
READINESS TRACKING — the campaign field
Every manifest declares readiness: "verified" | "partial" | "stub" (REQUIRED — the compiler enforces it) plus readinessNote saying what's missing whenever it isn't verified. This is the platform's tracker for "which surfaces are verified correct and complete". Rules:
verified is EARNED: full completeness audit against the live page, curated groups, emitter wired, checklist green. Never stamp it aspirationally.
Any change that adds page data without declaring it, or declares without emitting, demotes the surface — update readiness in the same edit.
Mirrored to ui_surface.readiness; the admin board at /administration/ui/surfaces rolls up Verified / Partial / Stub / Unregistered (DB rows with no manifest). Readiness is code-owned — never edited in the DB.
OVERLAY SURFACES — windows are surfaces too
Overlay/window panels (file preview, quick tasks, markdown editor, …) get their own surfaces: they are among the most interaction-heavy UIs. An overlay surface declares overlayId (the id from features/window-panels/registry/overlay-ids.ts) INSTEAD of urlPattern — the overlay twin of the route. Its emitter is a <SurfaceRuntimeProvider> mounted INSIDE the window component: nested providers out-depth the page's provider, so while the window is open ITS scope wins (by design — deepest wins). Values are "available while mounted": a window that always shows a file can promise file_id with alwaysAvailable: true.
🚨 Mount the provider AROUND the window's context menu, never BETWEEN the menu and its child.EditableContextMenu / NonEditableContextMenu render a Radix ContextMenuTrigger asChild, which clones its ONE child and hands it the trigger ref + onContextMenu; a non-DOM component (a provider) in that slot swallows both and right-click silently stops opening anything — measured live in TableViewerWindow on 2026-08-24. Correct order: <SurfaceRuntimeProvider><NonEditableContextMenu><div>… (the pattern WorkingDocumentEditor already uses).
The 4-step add (canonical)
1. Make sure ui_client row exists (matrx-user / matrx-admin / matrx-public / chrome-extension)
2. Make sure ui_surface row exists (name = "<client>/<local-slug>", FK → ui_client)
3. Add the manifest file + register (features/surfaces/manifests/...)
4. Sync the DB (POST /api/admin/surfaces/sync-manifests)
Then in the surface's code: emit an ApplicationScope via createXxxScope(...) and pass runtime: { surfaceName: "<client>/<local>" } to launchAgentExecution.
Naming contract
Thing
Rule
Enforced by
ui_client.name
Lowercase kebab. One of matrx-user, matrx-admin, matrx-public, chrome-extension (current set). New clients are rare — confirm with the user.
DB
ui_surface.name
"<client>/<local>" — single slash, kebab-case both halves. e.g. matrx-user/notes, matrx-admin/system-agents/agents.
DB FK + scripts/check-surface-drift.ts
SurfaceManifest.label
REQUIRED. Canonical display name, unique per client (case-insensitive).
Drift check + sync mirror
SurfaceValue.name
^[a-z][a-z0-9_]*$ — lower snake_case, must be unique within the surface. Becomes the key in ApplicationScope.
DB CHECK constraint + drift check
SurfaceValueGroup.key
^[a-z][a-z0-9_]*$; general / baseline / inherited:* are RESERVED (registry-synthesized).
Registry throws + drift check
Manifest filename
<local-slug>.manifest.ts (the part after the slash). Same kebab as the surface.
Convention
Exported manifest const
<localSlug>Manifest (camelCase from kebab).
Convention
If the user asks for a surface name that doesn't match ^[a-z][a-z0-9-]*\/[a-z0-9-/]+$, push back before writing anything — the drift script will fail otherwise.
Required reading before editing manifests
These are short — read them when the task is non-trivial:
features/surfaces/manifests/registry.ts — register your manifest in RAW_MANIFESTS; ALL_MANIFESTS is derived from it (inheritance resolved, baselines injected, provenance + groupKey stamped) and is what everything consumes
features/surfaces/manifests/marketing-page.manifest.ts — THE reference implementation: 40+ values, 7 curated groups, full contract, inheritsFrom chain (marketing-site → marketing-brand), scope builder in a separate runtime module (features/marketing/lib/marketing-page-scope.ts), emitter in PageWorkspace.tsx
features/surfaces/manifests/notes-editor.manifest.ts — the simple case (baseline + specific + in-file scope helper)
intro — the surface's self-introduction. A single XML-ish block (<surface_intro>…) telling the agent what this surface IS, what the user does here, and how to read its values. Written from a close understanding of the surface's PURPOSE — this is the first surface-context item the agent sees. Mirrored to ui_surface.intro. Every Tier-1 surface should have one. It describes the SURFACE and the user — never the model: no "You are the agent for…" / "Your job is…" framing (the bound agent's identity and rules live in the DB agent; pnpm check:hardcoded-prompts flags intros that assign the model a role — say "You are on…" / "The work here is…").
For agentRoles, configNamespaces, evidenceSources, and skipBaselineValues, follow End-to-end layered registration below.
Curated groups author sortOrder 0–899. Everything above is reserved for registry-synthesized groups.
Every SurfaceValue.group must reference a declared group key. Ungrouped own values land in the synthesized general group.
Reserved keys general, baseline, inherited:* may NOT be declared — the registry synthesizes them (throws at module init if you try).
The registry stamps every resolved value with provenance (own / inherited / baseline) and a groupKey, synthesizes inherited:<parent> groups ("Inherited from ") and the baseline group ("Generic baselines"), and sorts values by (group sortOrder, value sortOrder): curated groups first, inherited next, baselines LAST.
Groups mirror to ui_surface.value_groups (JSONB) and per-value ui_surface_value.group_key on sync; DB drift shows as valueGroupsDrifts.
Group like the page reads: identity, intent, evidence, content — see marketing-page.manifest.ts's 7 groups.
The SurfaceValue shape — every field matters
interface SurfaceValue {
name: string; // snake_case, unique in surface, regex-checked
label: string; // canonical human label — THE NAMING LAW applies
description: string; // 1-2 sentences. WHEN it's populated AND what it represents
valueType: "string" | "number" | "boolean" | "object" | "array" | "document";
alwaysAvailable: boolean; // true ONLY if the surface guarantees it on every launch
typicalCharCount: number; // avg stringified size — drives context-window warnings
autoContext?: boolean; // default true — auto-added to agent context; false = bindable-only
group?: string; // key of a declared SurfaceValueGroup; omitted = general
sortOrder?: number; // optional, defaults to 1000 in DB; orders within the group
}
Each field has rules. Don't half-fill the manifest — binding UIs, on-page chrome, and the LLM all consume this.
name
Lower snake_case. The regex is ^[a-z][a-z0-9_]*$.
Becomes the key the surface emits in ApplicationScope. Match what the surface actually puts in the bag.
Prefer reuse from BASELINE_VALUES (selection, content, context, text_before, text_after) — that's how the v3 context menu and existing agent shortcuts keep working without remapping.
label
2-4 words, sentence case. THE canonical name — the mapping editor, the Surface Context window, and on-page section/field chrome (via surfaceValueLabels) all render exactly this string.
1-2 sentences. Must cover WHEN it's populated and WHAT it represents.
Mention the empty case explicitly. The mapping UI shows this on hover; the LLM uses it when the binding goes through.
Good: "UUID of the note the user has open. Empty when no note is open (e.g. on the notes list)."
Bad: "The current note."
valueType
Drives the mapping editor's input affordance and validation.
Almost everything stringifies for LLMs at runtime — pick what reflects the JS shape the surface emits, not what the LLM "sees."
array for ID lists, tab lists, file lists.
object for free-form bags and for composite group values (like page_intent) — prefer named values over an unstructured object.
alwaysAvailable — get this right
This is the most-abused field. Only set true when the surface code literally always writes this key on every single launch, regardless of UI state.
Case
Verdict
open_tab_ids in an editor (could be empty array but always an array)
true
current_file_id in an editor that requires a file open
true
current_file_id in an editor where the user might be on an empty workspace
false
selection anywhere
false
content (full file body)
false (only true if you guarantee non-null)
The createXxxScope TS helper uses this to mark keys as required (no ?) vs optional (?). Lying here defeats the "a UI cannot lie" enforcement.
autoContext — signal vs noise
Declaring many values is MANDATORY (THE COMPLETENESS LAW); auto-shipping them all to the agent is NOT. Ask: what does an agent on this surface truly need? (a note surface: id, content, cursor, open tabs — not everything you could enumerate). Those keep autoContext: true (default); everything an agent could look up from an id is "inconvenient but resolvable" → autoContext: false (bindable-only). Mirrored to ui_surface_value.auto_context.
alwaysAvailable is earned by ROUTING
A value can only be guaranteed when the surface's identity lives in the URL. notes/[id] can promise id, content, cursor_position, selection on every launch (even when empty) — a surface whose active record is component state cannot. Tab test: tab-as-route → surface values are guaranteed and precise; tab-as-state → they're useless. The ideal shape is list page → [id] page → per-tab routes → URL params. When authoring a manifest for a surface without solid dynamic routing, flag the routing gap to the user — moving the surface toward routed identity is often worth more than more values.
typicalCharCount
Estimate after stringification (numbers → "42", objects → JSON.stringify).
Used by binding UIs to warn agent engineers when they bind a variable to something big enough to blow LLM context.
Don't be precise. Reasonable bands:
IDs / short labels: 36 (UUID), 60–120 (titles)
Selections / snippets: 200–500
Lists of IDs: (36 + 2) * typical_count
Full document bodies: 5000–20000
sortOrder
Optional. Defaults to 1000 in the DB. Orders values within their group.
The baseline values are 100/110/120/200/9999 — leave headroom around them and increment by 10 within your own values (300, 310, 320…).
Write targets — the WRITE half of a surface (v1, 2026-07-29)
A surface may declare writeTargets (SurfaceWriteTarget in features/surfaces/types.ts): named paths agent results can write INTO the page. Read features/surfaces/FEATURE.md § Surface writeback before adding one. Rules:
Declare in the manifest, wire on the provider. Each target gets a handler in SurfaceRuntimeProvider getWriteHandlers={() => ({...})}; a declared-but-unwired target fails LOUDLY at apply time — never ship one without the other.
Every caller goes through applySurfaceWrite (features/surfaces/runtime/surface-writeback.ts) or the apply_surface_write kind action — never a bespoke callback into the page.
mode is the safety contract:draft (stage into the editor, USER saves — the preferred default), entity (immediate persist via the page's canonical write path — reserve for writes safe to land directly), ui (ephemeral selection/focus/navigation).
updatesValue names the read-twin SurfaceValue when 1:1 (the evidence loop). Same naming regime as values (snake_case, unique, declared group); pnpm check:surface-drift validates all of it.
Handlers validate and THROW on bad input — the seam converts throws to safe error envelopes; never validate loosely to "be nice".
Code-only v1: not mirrored to the DB yet. Reference implementation: content-plan-node.manifest.ts + NodePanel.tsx (10 draft field targets + save_node).
The manifest file (full-contract template)
/**
* Surface manifest — <Human surface name> (`<client>/<local>`).
*
* 1-2 sentence summary of what this surface is and when it emits values.
*/
import type {
SurfaceManifest,
SurfaceValue,
SurfaceValueGroup,
} from "@/features/surfaces/types";
import { mergeBaselineValues, pickBaseline } from "./_baseline.manifest";
const groups: SurfaceValueGroup[] = [
{ key: "thing_identity", label: "Thing identity", sortOrder: 100 },
{ key: "thing_content", label: "Thing content", sortOrder: 200 },
// curated band is 0–899; general/baseline/inherited:* are reserved
];
const surfaceSpecific: SurfaceValue[] = [
{
name: "current_thing_id",
label: "Active thing",
description:
"UUID of the thing the user has focused. Empty when none is open.",
valueType: "string",
alwaysAvailable: false,
typicalCharCount: 36,
group: "thing_identity",
sortOrder: 300,
},
// ... EVERY field the page loads (THE COMPLETENESS LAW), plus natural
// composite values (e.g. a `thing_summary` object alongside its fields)
];
export const <localSlug>Manifest: SurfaceManifest = {
surfaceName: "<client>/<local>",
label: "<Canonical Display Name>", // REQUIRED — THE NAMING LAW
urlPattern: "/things/[thingId]",
inheritsFrom: "<client>/<parent>", // omit when standalone
intro: `<surface_intro>
What this surface IS, what the user does here, how to read its values.
</surface_intro>`,
groups,
values: mergeBaselineValues(
pickBaseline("selection", "context"),
surfaceSpecific,
),
agentRoles: [ /* see End-to-end layered registration below */ ],
};
The scope builder — where it lives
Simple surface (few values, trivially assembled): export create<LocalSlug>Scope(values): SurfaceScopePayload from the manifest file itself — see notes-editor.manifest.ts. Required keys (every alwaysAvailable: true value) get no ?; optional keys get ?. THIS is the "a UI cannot lie" enforcement.
Complex surface (raw workspace data needs parsing/derivation): put a runtime builder module beside the feature, not in the manifest — see features/marketing/lib/marketing-page-scope.ts (buildMarketingPageScope). The pattern:
The module takes the page's RAW loaded data (records, snapshots, memberships) and derives the typed values (parse stored JSON, compute availability, map rows).
When inheriting, it builds the parent scope first and spreads it: ...base first, child keys after — child wins.
It returns through the manifest's create<LocalSlug>Scope(...) so TS still enforces the declaration.
The page's emitter (e.g. PageWorkspace.tsx) calls the builder at trigger time with live refs, never stale state.
THE FAMILY DOCTRINE — what a parent conveys, what a child owns
Inheritance is not a convenience; it is how one CONCEPT keeps ONE name across a
whole family so an agent bound once works everywhere. Get the division of
labour right and children stay tiny; get it wrong and you split the vocabulary.
The division of labour
The PARENT conveys
The CHILD owns
The container's identity — the ids and names every descendant is inside (brand_id, site_id, project_id). Almost always alwaysAvailable.
What only this screen can see — the record on screen, the selection, the editor's live state (page_content, current_note, draft_content).
Context every descendant would otherwise refetch — the shared blob an agent needs to reason at all (brand_context, site_context).
Its own identity: label, readiness, intro, curated groups, its own scope builder. Never inherited.
Family-wide rollups that are true anywhere in the family (open_findings_total, pages_total).
Its write targets and agent roles — a child binds its own agents; it does not inherit a parent's job.
The five rules
Inherit only when the parent's whole vocabulary is TRUE on the child. A sibling that cannot emit the parent's values must NOT inherit — it is a different family. (Same shape ≠ same surface: working-document and scratchpad share a value set and stay separate because purpose and bound agents differ.)
Never re-declare what the parent conveys — that is a SHADOW: one concept, two declarations, and bindings land on whichever copy the author happened to see. Same meaning → delete the child's copy (the scope builder still takes it as a param). Different meaning → it needs its OWN name. pnpm check:surface-impact reports these as SHADOWED_VALUE.
The one exception — THE AVAILABILITY OVERRIDE: the parent always has the value, this child only sometimes does. Re-declare it with the SAME name and type and alwaysAvailable: false. That is the honest declaration, the screamer does not flag it, and deleting it would convert an under-promise into a promise the child cannot keep — the value-mapping guard then screams at runtime. Widening (child true where the parent says false) is forbidden unless the child truly emits it every time.
Push a value UP the moment a second child needs it. Two siblings declaring the same concept is the missing-parent smell: move it to the parent (or introduce one), delete both copies, repoint nothing — the name did not change.
A parent value is load-bearing for the whole family. Before you touch one, run pnpm check:surface-impact <parent> — it prints every descendant plus every binding/shortcut/write-twin, including ones that arrived via child <name>. brand_id on marketing-brand has 21 descendants; renaming it is 21 scope builders and every binding under them.
Depth ≤ 3, and never inherit for convenience. The registry throws at module init on an unknown parent, a cycle, or depth > 3. If you want a parent only to avoid retyping five values, you want a copy, not a family.
The shape, in the live marketing family (brand → site → page, the deepest we have):
marketing-brand 12 own brand_id*, brand_name, brand_context, brand_profile, … 21 descendants
marketing-site 11 own site_id*, site_name, site_root_url, site_context, … 18 descendants
marketing-page 60 own page_id*, page_url*, page_content, observed_*, findings, … leaf
The child declares ONLY its own layer; site_id and brand_id arrive by
inheritance and become REQUIRED params of createMarketingPageScope, so a page
can never launch an agent without its ancestry's identity. That is the whole
point: the agent bound to brand_context works on the brand cockpit, on every
site, and on every page, with one binding.
Mount-less / server-emitted children. "Inherited alwaysAvailable → REQUIRED param" and "...base spread FIRST" assume a client SurfaceRuntimeProvider that can hand the child its parent's scope. When the scope is assembled server-side (or by a job) there is no parent scope at runtime, and forcing the params would make callers fabricate values they do not have. Then: keep inherited keys OPTIONAL, take an optional inheritedBase and spread it FIRST in the body, and write the reason beside the builder. Honor the rule structurally, not ceremonially.
Fixing a family that is already wrong — do it in this order, in one change:
run check:surface-impact on the parent and each child · move the concept to
the parent · delete the children's shadows · update each child's scope builder
(inherited alwaysAvailable → required param, ...base spread FIRST) ·
re-run the screamer until the SHADOWED_VALUE rows for that family are gone ·
sync the DB · pnpm check:surface-drift.
INHERITANCE WORKED EXAMPLE — marketing-page → marketing-site
marketing-page declares inheritsFrom: "matrx-user/marketing-site" (which itself inherits marketing-brand). What that means for the child's scope helper:
Inherited alwaysAvailable: true keys become REQUIRED params in the child's builder.site_id / brand_id are guaranteed by the parent, so buildMarketingPageScope takes them as non-optional inputs and createMarketingPageScope requires them — the child can never launch without its ancestry's identity.
Inherited optionals become ? params — site_context / brand_context flow down when the host loaded them.
The child's builder composes: build/receive the parent's scope, return createMarketingPageScope({ ...base, page_id, page_url, ... }) — spread ...base FIRST so child keys win on collision.
In the resolved registry, inherited values land in synthesized inherited:matrx-user/marketing-site / inherited:matrx-user/marketing-brand groups, sorted after the child's curated groups and before baselines.
Inherit only when the parent's vocabulary is TRUE on the child. A sibling that doesn't emit the parent's values must NOT inherit.
Baselines are auto-injected — opting out
The registry injects the full baseline set into every manifest (withInjectedBaselines in registry.ts) so agent authors can bind generic values on any surface. A same-named value you declare wins over the injected one; baseline-named values always land in the synthesized baseline group. Passing [] to mergeBaselineValues does NOT skip baselines — the registry re-adds them. A surface with genuinely no text/content concept (e.g. a metadata-only widget) opts out with skipBaselineValues: true on the manifest.
Wiring it up
Create the file at features/surfaces/manifests/<local-slug>.manifest.ts.
Register in features/surfaces/manifests/registry.ts:
check:surface-drift validates manifest invariants (unique names, regex, valueType, surface-name shape, label presence + per-client uniqueness, group key/band/label rules). It validates manifests against themselves and is blind to route coverage — which is how ten live /agents/shortcuts routes pointed at a surface with no manifest and no DB row until 2026-08-17.
check:surface-routes closes that: it walks every (core) route through the real resolver. A mapping pointing at a surface with no manifest fails (a PHANTOM); a route resolving to nothing is reported unless it carries a written reason in that script's DELIBERATELY_UNMAPPED list. Never silence a route by adding it there without a real reason — that re-creates the blindness.
Sync the DB:
From the Surfaces admin page (/administration/ui/surfaces) → "Sync Manifests" button.
Or via API: POST /api/admin/surfaces/sync-manifests (super-admin gated).
The endpoint diffs ALL_MANIFESTS against the mirror and upserts — including ui_surface.label + value_groups (ALWAYS written) and per-value group_key. If a ui_surface row is missing for the surface, it's reported as skippedMissingSurface — you must seed the ui_surface row first.
Seeding the ui_surface row
If you're adding a brand-new surface (not just adding values to an existing one), the ui_surface row must exist before the sync will accept SurfaceValues:
Easiest path: open /administration/ui/surfaces → "New Surface" → pick the client + enter the name.
If the surface is in the curated candidates list (features/surfaces/data/surface-candidates.ts), the admin "Add from candidates" dialog seeds it in one click.
Seeding a new ui_client row
Rare. Only when the user explicitly asks for a new client domain (e.g. a new mobile app). Confirm first; then:
Runtime side — making the surface actually emit values
In the surface's launching code (button, context menu, AgentGenerator, etc.):
import { create<LocalSlug>Scope } from "@/features/surfaces/manifests/<local-slug>.manifest";
import { launchAgentExecution } from "@/features/agents/redux/execution-system/thunks/launch-agent-execution.thunk";
dispatch(
launchAgentExecution({
agentId,
runtime: {
surfaceName: "<client>/<local>", // ← MUST match ui_surface.name
applicationScope: create<LocalSlug>Scope({
current_thing_id: currentId,
selection: selected ?? undefined,
content: bodyText ?? undefined,
// ... never pass keys not declared in the manifest
}),
},
}),
);
The thunk at features/agents/redux/execution-system/thunks/launch-agent-execution.thunk.ts reads runtime.surfaceName, fetches the agent's binding layers via fetchSurfaceBindingLayers (bindings are platform.associations edges read through the agent.menu_surface view — written ONLY via features/surfaces/services/bind-agent-to-surface.service.ts), merges layers weakest→strongest, applies value_mappings via the resolver, and falls back to legacy auto-name-matching for unmapped keys. If you skip surfaceName, you get the legacy auto-name-match path only — explicit mappings won't apply.
Highlight-on-page (Locate)
Pages tag the DOM element that renders a value with data-surface-value="<value_name>". The Surface Context window's Locate button scrolls to and flashes it (features/surfaces/utils/locate-on-page.ts). SectionCard / MetricCell in features/marketing/components/shared/MarketingUi.tsx take an anchor prop for this. Tag anchors as you build the page — a value with no anchor can't be located.
Hierarchy chrome
Chrome reads ancestry/children from the REGISTRY — getSurfaceAncestry / getSurfaceChildren via getRelatedSurfaces (features/surfaces/runtime/fetchRelatedSurfaces.ts, synchronous). The Agents popover renders the full breadcrumb from it. ui_surface.parent_surface_name is a mirror only — never read it for hierarchy in chrome.
Updating an existing manifest
Adding a value: append to surfaceSpecific (with its group), update the scope-builder signature, re-sync. Existing bindings keep working — the new value just becomes available to bind against.
Removing a value: delete from the manifest. Sync will mark its DB row as dbValuesNotInManifest in the drift report. Any existing surface_value bindings whose target matches will show up as brokenAgentMappings / brokenToolMappings — admin uses the drift dialog's "Remap to…" / "Remove" / "Keep & notify" actions. Never silently delete DB rows that have bindings against them.
Changing a field on an existing value (description, label, alwaysAvailable, typicalCharCount, group): edit in place. Sync upserts. The drift report's diffs list will show the field-level diff until the sync is applied. If alwaysAvailable flipped from false → true, also update the scope-builder signature so the type system catches missing keys in surface code.
Removing a manifest entirely
Delete the manifest file.
Remove the import + reference in registry.ts.
Run pnpm check:surface-drift (should pass).
Run the DB sync — drift report will show every value as dbValuesNotInManifest. Admin decides whether to purge or keep them while existing bindings migrate off.
Eventually drop the ui_surface row when no bindings remain. Do not delete the row first — it cascades.
Things to avoid
Stuffing everything into context. It's escape-valve only. Each named field is queryable in binding UIs; context is opaque to the LLM in mapping previews. If the surface emits 5 obvious things, declare 5 SurfaceValues.
Hand-typing a label anywhere. THE NAMING LAW: chrome and on-page section/field text render through getSurfaceDisplayLabel / surfaceValueLabels / surfaceGroupLabels — never a string literal that duplicates a manifest label.
Leaving loaded data undeclared. THE COMPLETENESS LAW: an "Undeclared (runtime only)" entry in the Surface Context window is a defect.
Lying about alwaysAvailable. This breaks the scope-builder type guarantee. If the surface code has any if branch that skips writing a key, that key is false.
Generic descriptions. "The user's note" tells the LLM nothing. "Markdown body of the note the user has open. Empty when no note is open." is correct.
Declaring a reserved group key.general / baseline / inherited:* are registry-synthesized; declaring one throws at module init.
Mismatched surfaceName.ui_surface.name, the manifest's surfaceName, and the runtime runtime.surfaceName must be byte-identical. Binding edges reference the surface by uuid (platform.associations.target_id), so a name mismatch doesn't break stored bindings — it silently resolves NO bindings at launch.
Skipping the scope helper.dispatch(launchAgentExecution({ runtime: { applicationScope: { selecton: "..." } } })) — typo, no TS error, silent miss at runtime. Always go through the scope builder.
Inventing a baseline-style key. If you find yourself adding selection or content to surfaceSpecific instead of spreading from baseline, stop — you'll fork the description and confuse mappings. Spread from BASELINE_VALUES and override only when the surface's semantics genuinely differ.
Forgetting to update the helper signature when alwaysAvailable changes. The signature is hand-maintained; the drift script doesn't verify it.
ui_surface row exists with the exact <client>/<local> name
<local-slug>.manifest.ts created in features/surfaces/manifests/
Manifest imported + included in RAW_MANIFESTS in registry.ts
Full contract present: label (canonical, unique per client), urlPattern, intro, groups (curated band 0–899), inheritsFrom where true
Completeness sweep: every piece of data the page loads is declared — fields AND natural composites; no "Undeclared (runtime only)" entries in the Surface Context window
Every SurfaceValue has: a snake_case name, a canonical label, a 1-2 sentence description covering the empty case, a correct valueType, an honest alwaysAvailable, a sensible typicalCharCount, and a group
Scope builder exists (in-file for simple surfaces, runtime module for complex ones); required (no ?) keys match every alwaysAvailable: true value INCLUDING inherited ones
On-page section/field chrome renders via surfaceValueLabels / surfaceGroupLabels — no hand-typed label strings
Page elements tagged data-surface-value anchors for Locate
pnpm check:surface-drift passes
pnpm check:surface-routes passes — no phantom mapping, and this route is not silently undeclared
DB sync applied (admin UI or )
If anything in the checklist is unclear, re-read the relevant section above instead of guessing — the resolver is unforgiving when the contract drifts.
End-to-end layered registration
Registering a surface is a LAYERED recipe — each layer is independently shippable, and a manifest with no emitter is still useful (bindings work; live values land later). Layer 1 (the manifest) is everything above. Read first:features/surfaces/FEATURE.md (binding model, inheritance, roles/config) · features/surfaces/manifests/README.md.
Layer 2 — Agent roles + config namespaces
Agent role = a named position the surface PLUGS an agent into (agentRoles; cleanup's clean + custom_slot, scribe's assistant). defaultAgentId = platform default; users/orgs override in ui_surface_agent_pref, resolved manifest → global → org-by-membership → user by services/surface-config.service.ts. A system agent's role sets mandateKey (e.g. "masterwork.scout") INSTEAD of defaultAgentId — the Holder resolves live from agent.mandate (sourceTier "mandate"); never freeze an agent UUID in a manifest for a job that has a Mandate (drift check refuses both set at once; reference: masterwork-rulebook.manifest.ts). Roles with a resolved agent surface automatically in the shell header Agents menu (SurfaceAgentsHeaderButton → SurfaceBoundAgentsList "Surface roles") and launch with the page's live scope — never build a bespoke per-page agent menu. Disclosure never adds chips, badges, labels, rosters, or any other visible page content. Pages read via hooks/useSurfaceConfig.ts / useSurfaceAgentRoles. Never store a per-surface agent choice in userPreferences / useSetting — that's the exact legacy this system deleted (scribeAssistantAgentId).
🚨 THE DISCLOSURE LAW is top-menu-only metadata, never page UI. Register only a fixed AI job an eligible ordinary product surface already runs through agentRoles or UI-free useDeclaredSurfaceMandates, and open its mandate IN PLACE via useOpenMandateWindow() rather than linking to a mandate route. Never add an agent chip, badge, label, roster, callout, or section to the surface. Agent-native hosts accept no roles or surface bindings. Invoke the agent-disclosure skill for the full boundary and verification. Guard: pnpm check:agent-disclosure.
Config namespace = a typed JSONB bucket in ui_surface_config (dictionary, session_defaults). Adding one = a PURE handler (validate/merge/empty) in config/namespace-registry.ts + a manifest line. Zero SQL.
Layer 3 — Registry + drift gate
Import + add to RAW_MANIFESTS in manifests/registry.ts, then pnpm check:surface-drift and pnpm check:surface-routes must both pass before anything ships.
Layer 4 — DB sync (a manifest not synced is not registered)
A ui_surface row must EXIST first (surfaces admin /administration/ui/surfaces, or SQL insert with client + sort_order tier).
Canonical sync: POST /api/admin/surfaces/sync-manifests (surfaces admin button). From an agent shell: pnpm tsx scripts/emit-surface-sync-sql.ts → run the upsert via Supabase MCP (mirrors manifest-sync.service.ts).
Verify live — count ui_surface_value / ui_surface_agent_role rows for the surface; then pnpm check:surface-drift again (the live count is the real DB check).
Layer 5 — Runtime emitter (buildScope)
An eligible ordinary surface assembles its scope with createXScope(...) at trigger time (read live refs, not stale state) and launches with runtime.surfaceName set — via the v3 context menu (EditableContextMenu / NonEditableContextMenu) surfaceName= + getApplicationScope, useAgentLauncher().launchAgent, or useAiPostProcess. Cleanup's emitter: CleanupPad.tsxbuildScope().
An agent-native surface's primary launch passes explicit runtime: { surfaceName: null }. Its neutral <SurfaceRuntimeProvider> may remain for context-menu identity, but it supplies no roles, bindings, bound roster, or Bind control.
Baseline selection/text_before/text_after are captured by the menu itself — don't duplicate.
Layer 6 — Bindings + verification
Skip this layer entirely for agent-native surfaces. Their own agent is the
subject, and no default, role, surface binding, bound roster, or Bind control is
created for it.
Bindings are platform.associations edges (agent → surface, tier-encoded role, value_mappings in edge metadata), written ONLY through services/bind-agent-to-surface.service.ts — UI paths: SurfaceAgentBindPanel, the 5-panel /agents/[id]/surfaces shell, or the batch editor. Never write an edge by hand.
Verify like the owner does:
Bind a test agent with deliberately non-matching names (cleanup's template: agent Cleanup Surface Demo Reporter 42971fe0, working_text ← raw_transcript_text) so name-heuristics can't mask a broken mapping.
Launch from the surface; confirm the mapped variables arrived: cx_conversation.variables is the DB forensics.
The Matrx-vs-matrix test (Arman's standard): put "Matrx is the product name (not matrix)" in a bound context value, feed input containing "matrix", check the output spells Matrx. If it doesn't, the context never reached the agent — a silently-skipped binding, the exact bug class this system exists to kill.
Recovery layers must be LOUD (console.warn/error + toast) — a silent skip is how the org-tier bug survived.
Registration ship checklist
readiness stamped honestly (verified only after the full checklist; note required otherwise); overlay surfaces carry overlayId
Manifest + scope builder; required label; groups declared + every value grouped; completeness sweep clean; honest values; baselines not duplicated
Roles/namespaces declared where the surface plugs in agents/config
Registered in registry.ts; pnpm check:surface-drift AND pnpm check:surface-routes green
DB synced AND live row counts verified
Route prefix in utils/route-to-surface.ts (more-specific prefixes ABOVE their parent)
Emitter wired (or explicitly deferred in the manifest header comment)
Eligible ordinary surface: non-matching-name binding + Matrx-vs-matrix test passed live; agent-native: N/A with surfaceName: null and zero-role/binding/Bind proof