# Reskin

> Author a NEW skin for the reskinnable-demo app. A skin is a self-contained domain plugin under src/skins/<id>/ that implements the frozen `Skin` contract (src/shell/skin-contract.ts) to swap the app's entire experience — brand, theme, layout, pages, tools, data, and agent — as a live sales demo. Use when the user says "add a skin", "create a skin", "new skin", "reskin the app", "make a <domain> skin", or wants the app re-themed as a new product. Do NOT use for editing the shell itself (src/shell/**), the shared token vocabulary (src/app/globals.css), or the shipped skins unless explicitly asked.

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

---


# Authoring a reskinnable-demo skin

This app hosts one skin-agnostic **shell** (`src/shell/`) that renders one
**skin** per URL segment `/[skin]/...`. A skin is a domain plugin living entirely
under `src/skins/<id>/`. Its ONLY inbound dependency is the frozen `Skin`
contract in `src/shell/skin-contract.ts` — that is what lets skins be authored
in isolation without touching shared code.

To add a skin you (1) map the demo beats it must hit, (2) implement the `Skin`
contract in `src/skins/<id>/`, (3) put its **server-only** agent in
`src/skins/<id>/agent.ts`, and (4) register both — the client skin in
`src/shell/registry.ts` and the agent in `src/shell/agent-registry.ts`, keyed by
the identical `id`.

> Before writing anything, re-open `src/shell/skin-contract.ts` (the source of
> truth) and read the shipped skins as worked references (`ls src/skins/` is the
> registered set — do not memorise a count). They are good at different things;
> **[demo-beats.md](./demo-beats.md) § "Which skin to copy for what"** is the
> routing table. The short version: **every registered skin but `bookstore` is
> demo-complete**, so nearly any of them is a fair end-to-end reference, and what
> you pick between is which one is cleanest for the problem in front of you —
> `banking` the original reference, `people` and `commerce` the beat-first pair
> whose beat maps are written out in their `suggestions.ts`, `logistics` the
> layout reference, `airline` runtime identity without
> `RuntimeProviders` (and an entitlement-shaped rather than authority-shaped
> beat-6 gate), `keel` the fullest parameterized routing, `bookstore` the only
> `useData` implementor and the worked example of a beat map with two rows marked
> `SKIPPED` rather than deleted, `exec` the BI/executive-analytics domain and the
> worked example of the `block:`-prefixed inline a2ui convention (an alternative
> to `CanvasSurface` — see "A `block:`-prefixed a2ui surface…" below). Those
> files win on any conflict with this skill.
>
> **Do not model a new skin on the ABSENCE of a field.** Most optional fields are
> set by most skins, and every omission in the tree has a stated reason next to it,
> so "airline omits it" is not permission to omit it. Derive what a skin sets
> rather than trusting prose:
> `grep -nE '^\s+(Providers|CanvasSurface|sandboxFunctions|toolLabels|chatHeaderActions|onSuggestionSelect|RuntimeProviders|useRuntimeProperties|useData)[,:]' src/skins/*/skin.tsx`.

---

## ⚠️ FIRST: a skin is a live sales demo, not a theme

A skin exists to prove CopilotKit and Intelligence top to bottom, in front of a
Fortune 500 buyer. Wiring the contract correctly is table stakes; a skin that
compiles, looks sharp and proves nothing is a **failed skin**. The banking demo's
~10 steps are tuned and land with customers — so copy its **beats**, not its
steps. Your domain can be 1000% different.

| Beat                  | The audience must conclude                                                           | Minimum mechanism                                                          |
| --------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| **1** Give it a face  | "Generative UI — right out of the gate."                                             | A `useComponent` visual answers pill #1                                    |
| **2** Rich thread     | "Reload the browser and the chart is still there. Nobody else stores AG-UI streams." | Durable visuals via `useComponent`; **replay-safe** tools                  |
| **3a** Drive the app  | "It changed the app — and the secret never reached the assistant."                   | A mutation whose sensitive payload stays in the UI                         |
| **3b** Sees my screen | "Shared state is real." (ask on two different pages)                                 | A **route readable** + per-page on-screen readables                        |
| **3c** Levers         | "That was a maneuver, not a link."                                                   | HITL confirm → navigate → sort **+** filter, visibly highlighted           |
| **3d** Multimodal     | "It takes real documents, and the output belongs to my app."                         | Attachment path + artifact written to the store, surviving thread deletion |
| **4** Memory          | "It remembers how I like things, and says so."                                       | Seeded topical memory + recall-first prompt + a slot naming the "why"      |
| **5** Stored skill    | "One sentence and it already knows our procedure."                                   | Seeded operational memory + 3 visible writes + distractors                 |
| **6** Teach a skill   | "It learned by watching me once, then did it alone."                                 | Symptom-only gate + unlock path + recording context + save/recall          |

**Write the beat map before you write code** — the table template and the full
per-beat spec are in **[demo-beats.md](./demo-beats.md)**, which also covers the
presentation requirements (a pill per beat so the presenter never types, a visible
affordance on every mutation, pretty markdown prose, a Reset control, the
chat-placement framing) and the quality bar.

**If the user named the beats** — fewer, more, or different — theirs win. Record
what they asked for in the beat map and build that. Absent instructions, build
all nine.

**Then read [failure-modes.md](./failure-modes.md), before you write tools or
pages.** It is the cross-cutting half of this skill, and its through-line is the
one thing to carry into every file: **a skin's characteristic bug is not a crash —
it is a confident falsehood.** A crash is visible on stage and gets fixed; a
convincing lie reads as success and proves nothing. An empty chart drawn with
confidence, a lever chip naming a choice the agent never made, a receipt for a
write that did not land, a readable reporting an all-clear it never checked — all
of those compile, lint, pass tests, and land as a successful demo. That file states
the principles and points at the shipped `commerce` code for each; the per-file
scaffolds stay in [templates.md](./templates.md).

⚠️ Beats **2, 4, 5 and 6 are runtime-conditional**: they need all three
Intelligence env vars — `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`
and `CPK_INTELLIGENCE_API_KEY`, which does NOT match an `INTELLIGENCE_*` glob
and is the one people miss — and beats 4/5 additionally need a seeded-memory file
(`src/skins/<id>/intelligence/seed-memories.ts`). Without those they degrade
**silently** — the agent simply doesn't know you. See demo-beats.md.

---

## ⚠️ CRITICAL: the client / server boundary

**The AGENT is server-only and is NOT part of the client `Skin` contract.**
`@copilotkit/runtime` must never be bundled client-side.

- Each skin puts its agent in a server-safe `src/skins/<id>/agent.ts` with
  **NO `"use client"` and NO JSX** — just:
  `export const <id>Agent = () => new BuiltInAgent({ ... });`
- The client `skin.tsx` **NEVER imports `agent.ts`.** The only link between them
  is the shared `id` (`id === agentId`).
- The client skin registers in `src/shell/registry.ts` (`SkinRegistry`); the
  agent registers separately in `src/shell/agent-registry.ts` (`agentRegistry`,
  as `{ createAgent, identifyUser? }`). Two registries, one id.

**Theming is a per-skin `theme.css`, never the shared globals.** The shell owns
the token _vocabulary_ in `src/app/globals.css` (`@theme inline` + the semantic
utilities `bg-surface`, `text-ink`, `border-hairline`, `shadow-soft`, `bg-brand`,
…). Do **not** edit `globals.css`. Instead create `src/skins/<id>/theme.css`
containing a single `.theme-<id> { … }` block that **re-values** the shared CSS
variables, and import it as a **side-effect** from the skin's `layout.tsx`
(`import "./theme.css";`). Your `skin.themeClass` must equal `"theme-<id>"` so the
shell applies your block. Never invent new token names — only re-value existing
ones.

**Dark mode is an explicit opt-in — `--nw-dark-capable: 1`.**
`src/hooks/use-theme.ts` forces any skin WITHOUT that flag to light and ignores
the stored dark preference, so a skin that writes a `.dark .theme-<id>` block but
forgets the flag stays stuck in light. To support dark you must do BOTH: set
`--nw-dark-capable: 1` on the `.theme-<id>` root AND ship a `.dark .theme-<id>`
block (which re-values only surfaces / ink / semantic tokens and lets the brand
ramp and `--radius` inherit). Omit both to stay light-only — a legitimate choice
(airline does exactly that). If you kept the theme toggle in your layout, note
it is a **dead control** until this flag and the dark block both exist.

**OGUI renders full-region on the shared canvas.** A `generateSandboxedUi` call
becomes an `open-generative-ui` activity that the shell renders full-region on
the canvas via the workspace `OpenGenerativeUIActivityRenderer`. A skin does
**not** supply an OGUI renderer — it only contributes
`sandboxFunctions?` + `designSkill`, which the shell wires onto the provider. (An
a2ui _report_ surface is different: a skin renders its own via the optional
`CanvasSurface`.)

**A `block:`-prefixed a2ui surface renders INLINE in the chat transcript
instead of the handoff pill — everything else still gets the pill + canvas
path unchanged.** The shell's chat activity renderer
(`A2UISurfaceActivity` in `src/app/[skin]/layout.tsx`, wired module-level into
the `A2UI_RENDERERS` array so the reference stays stable across renders —
`CopilotKitProvider` requires that) reads an `a2ui-surface` activity's
`content.a2ui_operations`, walks it for the first
`createSurface`/`updateComponents`/`updateDataModel` surface id, and checks
whether that id starts with `block:` (`blockSurfaceIdFrom`,
`src/shell/chat/inline-block-surface.tsx`). A match renders
`InlineBlockSurface` right where the activity message appears.

The canvas asks the same question a different way, and the difference matters if
you mint ids. `classifyA2uiSurface`'s `claimOf`
(`src/shell/canvas/canvas-context.tsx:61-78`) scans EVERY op rather than
stopping at the first one carrying a surfaceId, because a surfaceId can arrive
on any of `createSurface`/`updateComponents`/`updateDataModel` and a snapshot's
leading op is routinely something else. It returns "canvas" the moment it sees a
non-`block:` id, "inline-block" if it saw only `block:` ids, and
"unclassifiable" otherwise — biased against the canvas on purpose, since
claiming the canvas for content no `CanvasSurface` can read blanks the page. So
a mixed op list (one `block:` surface plus one report surface) reads as a canvas
claim there while the chat's first-id walk may still render it inline. Keep one
surface per activity and the two agree, which is what exec does.

**That card mounts its OWN `<A2UIProvider>` per
rendered activity, and must:** there is no ambient a2ui store on this path.
`CopilotKitProvider`'s `a2ui.catalog` prop mounts no provider — the only thing
that would is the built-in `a2ui-surface` renderer's `ReactSurfaceHost`, and the
shell's `renderActivityMessages` array SHADOWS that built-in (user-supplied
renderers resolve first), so `useA2UIActions()` would throw and take the page
down. The isolation is also deliberate: one provider per activity means a
block's surface state can never collide with, or be clobbered by, the canvas's.
The catalog comes from `useSkin().catalog` — the same object the layout hands
`CopilotKitProvider` — reached through the contract rather than by importing
from `src/skins/`. Anything else — including a `CanvasSurface` report like banking's
`render_report` or logistics' `renderBrief` — still falls back to
`ReportHandoffPill`; this convention adds a second inline path, it does not
change the existing one. Because the shell must not import from `src/skins/`,
the `block:` spelling is duplicated by hand in TWO places, both spelled
`BLOCK_SURFACE_PREFIX`: `src/skins/exec/blocks/build-block-ops.ts:22` (the
write side, the worked example a skin copies to mint its own ids) and
`src/shell/canvas/canvas-context.tsx` (the shell's single reader-side
decision, `decideA2uiSurface`; the chat's `inline-block-surface.tsx`
delegates to it rather than keeping a copy). Both ARE checked: the drift
guard in `src/skins/exec/blocks/build-block-ops.test.ts` runs freshly minted
ops through the shell's classifier, so either side drifting fails there.
Grep for `BLOCK_SURFACE_PREFIX` before you pick your own spelling.

**A `sandboxFunction`'s `parameters` schema is DOCUMENTATION, not a gate — and its
returns are undocumented unless the `description` says so.** Two traps, both of
which produce a generated panel that renders and is wrong:

- The provider serializes `parameters` into agent context and the renderer then
  hands your bare `handler` to the iframe (`api[fn.name] = fn.handler`). Nothing
  validates the arguments, so a loose parameter (`category: z.string()`) filters
  on a value nothing matches and returns `[]` — a convincingly blank view, with
  the model never told it guessed wrong. Enumerate every parameter to its real
  domain (`z.enum(YOUR_CONST_TUPLE)`, so the vocabulary reaches the model too) and
  parse the args in the handler, throwing a message that names the accepted
  values. Commerce's `define()` wrapper in `src/skins/commerce/sandbox-functions.ts`
  is the worked example. **One exception, and it is load-bearing: a beat-6 gate's
  unlock vocabulary must NOT be enumerated** — putting those codes in front of the
  model is exactly the defect, because then it never has to learn them. Take a free
  `z.string()` there and say so in the `.describe()`. See failure-modes.md § 10.
- The model never sees a sample result — only `name`, `description` and the
  JSON-schema-ified `parameters`. So a figure whose unit is not in its FIELD NAME
  must have it in the `description`: an unlabelled ratio (`0.418`) renders as
  "0.42%" or "41.8%" with equal confidence. Commerce ships ratios as
  `…Ratio` + a `…Label` string built with the app's own formatter, which also makes
  the generated panel read identically to the app card beside it.

**EVERY a2ui surface must be fed by a SERVER tool, never a client one — the
canvas `CanvasSurface` path and the inline `block:` path alike.** Emit the
`{ [A2UI_OPERATIONS_KEY]: buildOps(spec) }` payload from a **server-side
`defineTool` on the `BuiltInAgent` in `agent.ts`** — not from a client
`useFrontendTool`. The a2ui middleware only converts that payload into an
`a2ui-surface` activity when it observes it in an in-stream `TOOL_CALL_RESULT`
event, which a client frontend-tool result never produces. Do it client-side and
NO `a2ui-surface` activity is ever minted, so the canvas stays permanently blank
AND the inline block card never appears — the rule is about how the activity is
born, not about where it renders. Banking (`render_report`) and logistics
(`renderBrief`) do it server-side for the canvas; exec's `render_metric_block`
(`src/skins/exec/agent.ts`) does it server-side for the inline block path. The
`agent.ts` template shows the shape.

---

## The `Skin` contract, field by field

Quoted from `src/shell/skin-contract.ts` (the frozen interface). Diff your object
against that file; it wins.

**Required:**

| Field         | Type                                            | Purpose                                                                                         |
| ------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `id`          | `string`                                        | Stable id — **MUST equal the route segment AND the agent id.**                                  |
| `identity`    | object (below)                                  | Brand identity the shell renders.                                                               |
| `themeClass`  | `string`                                        | CSS class scoping this skin's tokens — set to `"theme-<id>"`.                                   |
| `Layout`      | `ComponentType<{ children: ReactNode }>`        | The app-shell chrome (nav/header) wrapping page content.                                        |
| `nav`         | `NavRoute[]`                                    | Nav entries the layout renders. **Display-only — NOT the segment validator** (see below).       |
| `resolvePage` | `(segments: string[]) => ComponentType \| null` | Maps URL segments (after `/[skin]`) to a page, or `null` → 404. **The sole segment validator.** |
| `Tools`       | `ComponentType`                                 | Registers frontend tools / HITL / gen-UI + agent-context readables. **Renders `null`.**         |
| `catalog`     | `A2uiCatalog`                                   | The skin's a2ui catalog from `createCatalog()`.                                                 |
| `suggestions` | `Suggestion[]`                                  | Static suggestion pills (`{ title, message }`), shown `available:"always"`.                     |
| `designSkill` | `string`                                        | OGUI design brief — injected as agent context to style generated UIs.                           |

`identity` object:

| Field            | Type                                    | Notes                                                                                                                                                  |
| ---------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `brand`          | `string`                                | Shown in the selector + chat header.                                                                                                                   |
| `tagline`        | `string`                                | Selector tooltip; default chat greeting when `greeting` omitted.                                                                                       |
| `logo`           | `ComponentType<{ className?: string }>` | Logo mark (inline SVG/glyph).                                                                                                                          |
| `favicon?`       | `string`                                | Emoji browser-tab icon (e.g. `"✈️"`). The shell's `FaviconSync` renders it into a `<link rel="icon">` per skin; omit to keep the static `favicon.ico`. |
| `assistantName?` | `string`                                | Chat header title. Defaults to `brand`.                                                                                                                |
| `greeting?`      | `string`                                | Chat welcome message. Defaults to `tagline`.                                                                                                           |

**Optional:**

| Field                   | Type                                                 | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Providers?`            | `ComponentType<{ children: ReactNode }>`             | Skin-specific provider stack mounted **below** `CopilotKitProvider` (escape hatch). Omit → shell substitutes a pass-through.                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `CanvasSurface?`        | `ComponentType`                                      | Renders the skin's own a2ui report surface full-region on the shared canvas. Omit if no a2ui report canvas — or if every a2ui surface you emit uses the `block:`-prefixed inline convention instead (see "A `block:`-prefixed a2ui surface…" above); `exec` omits `CanvasSurface` for exactly that reason.                                                                                                                                                                                                                                               |
| `sandboxFunctions?`     | `SandboxFunction[]`                                  | Functions exposed inside OGUI sandboxed iframes for this skin.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `toolLabels?`           | `Record<string, string>`                             | Human labels for this skin's OWN tool-activity chips, keyed by tool name. Unlisted tools fall back to a prettified raw name.                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `chatHeaderActions?`    | `ChatHeaderAction[]`                                 | Buttons this skin contributes to the shared chat header (drawn before the shell's own controls).                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `onSuggestionSelect?`   | `(suggestion: Suggestion, index: number) => boolean` | Intercept a suggestion click. Return `true` if fully handled (shell does nothing further); return `false`/omit for the default "send the message" path. `true` is a PROMISE that something happened — the handler it launches must either do the thing or tell the presenter why it could not (see beat 3d in demo-beats.md); `true` plus silence is the bug this contract keeps producing.                                                                                                                                                              |
| `RuntimeProviders?`     | `ComponentType<{ children: ReactNode }>`             | Provider stack mounted **above** `CopilotKitProvider` (unlike `Providers`, below). The sanctioned place to establish context your `useRuntimeProperties` must read — it has to sit above the provider so the provider owns `properties` from its first commit. See "Contributing end-user identity" below.                                                                                                                                                                                                                                               |
| `useRuntimeProperties?` | `() => Record<string, unknown> \| undefined`         | Contributes this skin's runtime `properties`; the shell threads the result into `CopilotKitProvider`'s `properties` prop. How a skin scopes its Intelligence runs / durable memory per end-user. Return a stable/memoized object. Omit if the skin contributes no runtime identity.                                                                                                                                                                                                                                                                      |
| `useData?`              | `() => unknown`                                      | Seed-backed data hook; the shell runs it in `SkinProvider`, components read via `useSkinData<T>()`. **The in-memory escape hatch — the minority path, and it splits exactly along the substrate line.** Derive who takes it: `grep -l 'useData:' src/skins/*/skin.tsx` names the implementors (`bookstore`, via `data/use-data.ts`); every other registered skin omits it and reads its REST ledger through its own context/hook, so there `useSkinData<T>()` returns `undefined`. Read the implementor first, templates.md § `data/use-data.ts` second. |

Supporting types (also in the contract):

```ts
export interface NavRoute {
  segment: string; // URL segment after the skin, e.g. "" (index), "cards".
  label: string;
  icon?: ComponentType<{ className?: string }>;
}
export interface Suggestion {
  title: string;
  message: string;
}
export interface ChatHeaderAction {
  icon: ComponentType<{ className?: string }>;
  label: string;
  onClick: () => void;
}
export type A2uiCatalog = ReturnType<typeof createCatalog>;
```

> The agent is deliberately absent from this interface — see the boundary
> section above. It lives in `agent.ts` and registers separately.

**`nav` does not decide what resolves.** It is display-only — the list the layout
draws as navigation. `resolvePage` is the single source of truth for which
segments are valid (the contract says so, `skin-contract.ts` around lines 86-92),
and it may accept segments `nav` omits (banking's `resolvePage` accepts a `cards`
index alias its `nav` never lists). So every segment a user can reach — nav
entries, aliases, deep links — must be handled in `resolvePage`; anything it
returns `null` for is a 404, regardless of what `nav` contains.

---

## The layout contract (viewport height + nav insets)

One thing every shipped `Layout` gets right and the naive version gets wrong —
`src/skins/logistics/layout.tsx` is the reference, and the template mirrors it:

- **The root is `h-full overflow-hidden`, NOT `h-screen` or `min-h-screen`.** Your
  chrome fills the shell's app CARD, not the viewport — the frame insets that card
  by its own padding, so a viewport-height root overflows it by exactly that much.
  It still has to be BOUNDED, though: if the container can grow past the card the
  whole document scrolls, the pinned nav scrolls away with it, and `<main>`'s own
  `overflow-y-auto` goes inert because its parent is unbounded. `h-full
overflow-hidden` on the root, plus `h-full` on the `<aside>`, so only `<main>`
  scrolls.

> **Do not publish `--nw-nav-inset-left` / `--nw-nav-inset-right`.** Nothing reads
> them: the switcher is a dropdown in a card at the top of the assistant column, so
> it occupies a slot and never overlaps your nav.

## The URL contract (never hardcode the skin prefix)

**Every in-skin link and `router.push` must go through `useSkinHref`**
(`src/shell/skin-path.ts`), and every "which nav entry is active" derivation
through its companion `useSkinSegments`. Both are in the layout template.

Skins live under `/[skin]` on the normal demo (one segment per registered skin),
but a `LOCK_SKIN` deploy is served **at `/`** with the segment gone from the URL
space entirely — `src/proxy.ts` rewrites the prefix-free space onto the route
tree. So:

- `skinHref("cards")` → `/banking/cards` unlocked, `/cards` locked.
- A hardcoded `` `/${skin.id}/cards` `` still RESOLVES under a lock, which is why
  this is easy to miss: it just puts `/banking` back in the address bar on the
  first nav click, and the single-tenant illusion is gone.
- A hand-rolled `pathname.split("/").slice(2)` is worse — it silently eats the
  first real segment when there is no prefix to skip, so under a lock every page
  reports itself as the index and the wrong nav entry highlights.

Deep links append their own hash:
`` `${skinHref(`knowledge/${docId}`)}#${sectionId}` ``. A skin with many
parameterized links should wrap the hook once for itself — see
`src/skins/keel/href.ts`, which exists so keel's id appears in exactly one place.

The one legitimate exception is a link to a DIFFERENT skin (the shell's skin
switcher), which must keep the prefix and only ever renders unlocked.

**`pnpm lint` enforces this** via `no-restricted-syntax` selectors in
`eslint.config.mjs` (scoped to `src/skins/**`, tests exempt). They fail and NAME
YOUR FILE if an in-skin path literal (i) opens with a skin id segment
(`"/banking/cards"`, `` `/keel/runs/${id}` ``), (ii) concatenates a path onto an
interpolated base (`` `${base}/charges` `` — the `//` shape) **when that template is
a navigation target**, or (iii) opens with a leading-slash interpolation
(`` `/${skin.id}/…` ``). The rule reads the AST, so a skin prefix inside a comment or
prose string is fine and a `$` in a variable name cannot fool it.

Selector (ii) is deliberately narrowed to navigation contexts: the `` `${x}/${y}` ``
shape is AST-identical to an ordinary date `` `${month}/${day}` `` or ratio
`` `${used}/${total} used` ``, so flagging it everywhere false-positives on any skin
component that formats a date or fraction. It therefore fires only when the template
is passed to `router.push`/`router.replace`, to `location.assign`, assigned to
`location.href`, or set as a JSX `href={...}`. Trade-off, stated honestly: a URL
built into a variable first and then navigated (`const u = `${base}/x`;
router.push(u)`) is NOT caught by (ii) — the literal-prefix guards (i)/(iii) still
catch the common hardcoding shapes regardless of use site. Because (ii) is now
nav-scoped, REST/data-layer files (`actions.ts`, `intelligence/**`) that build
absolute SERVER urls (`` `${BASE}/shipments` ``) never trip it anyway; they stay
explicitly scoped out as belt-and-suspenders.

## The meta-utility strip

The presenter/dev utilities — Reset, theme toggle, Help — are **skin-authored
chrome, not shell-provided**. A new skin gets none of them for free; you add them
in the layout (the template puts them in an `mt-auto` group at the bottom of the
sidebar). Three controls:

- **Reset** (`RotateCcw`) — render it only when `usePresenterReset()` (from
  `@/shell/presenter-reset-context`) is true; on click, `window.confirm` then
  `POST /api/<id>/v1/dev/reset` then `window.location.assign(skinHref())` for a
  pristine slate. **Branch on what the route says about the STORE, not on
  `res.ok`** — the route wipes the store first and can still answer non-2xx, so an
  ok-only branch leaves the page (and the readables describing it) asserting rows
  that are gone, and throws away the body's `memoryError` sentence, which is the
  only warning that beat 6 may start out already taught. See the scaffold in
  templates.md and `runPresenterReset` in `src/skins/commerce/layout.tsx`.
  This one deliberately IS a full document load rather than a
  `router.push` — dropping every module reload-fresh is the point (new store, new
  thread, cleared canvas) — but the URL it navigates to is still built by
  `useSkinHref`, exactly as in the layout template. Do **not** hand-roll it as
  `` `/${skin.id}` ``: that is shape (iii) from the URL contract above, so it fails
  `pnpm lint`, and on a locked deploy it re-introduces the tenant segment the
  reset is supposed to leave behind (`skinHref()` returns `/` there). Keep the
  button and the endpoint in agreement: your skin's own
  `dev/reset` route should allow the reset when `presenterResetEnabled() ||
process.env.NODE_ENV !== "production"` (mirror
  `src/app/api/logistics/v1/dev/reset/route.ts`), or a production booth shows a
  button that 403s.
- **ThemeToggle** — `import { ThemeToggle } from "@/components/ui/theme-toggle"`.
  It is a SHARED component under `src/components/ui`, so importing it is fine and
  is NOT a cross-skin import. Remember it is a dead control unless your skin also
  ships a dark palette (`--nw-dark-capable: 1` + a `.dark .theme-<id>` block — see
  the theming rules above).
- **Help** (`HelpCircle`) — calls a `useAskCopilot()` that opens the panel and
  sends a message as the user. **Port** it into your own
  `src/skins/<id>/components/use-ask-copilot.ts` (copy logistics'); do NOT import
  from `src/skins/banking/**` — a skin's only inbound dependency is the contract.

## Registering tools: deps, render signatures, replay safety, readables

Six rules that the `tools.tsx` template bakes in; miss any and the failure is
silent.

- **Every `useComponent` / `useFrontendTool` / `useHumanInTheLoop` /
  `useRenderTool` registration closes with a deps array.** Each takes an
  **optional deps array as a second argument**
  (`useFrontendTool(tool, deps?: ReadonlyArray<unknown>)`,
  `useHumanInTheLoop(tool, deps?)`, `useComponent(spec, deps?)`,
  `useRenderTool(config, deps?)` — the installed types confirm it). Do not skip
  `useRenderTool` because it is the rarer hook: it is exactly the one a skin
  reaches for when a render needs `status`/`result` (banking's, and exec's
  `file_variance_narrative`), which is live-data rendering, which is where a
  stale closure hurts most. The declarations live in the hashed bundle type
  file — `ls node_modules/@copilotkit/react-core/dist | grep d.cts` finds it
  (`copilotkit-B1K0Tgnz.d.cts` today; the hash changes on every SDK bump, so
  derive it rather than copying this one).
  Omit it and the closure captures whatever the data was at REGISTRATION time —
  for a REST-backed skin, the EMPTY array from before the first fetch — forever.
  This is the nastiest bug in the app because it **compiles, lints, and passes
  every test**: the agent narrates confidently ("the trade-offs are on screen")
  while the component renders its "not found" branch over stale data. Banking
  documents the same trap in a code comment (search "closure captures empty
  arrays" in `src/skins/banking/tools.tsx`); logistics passes deps on every
  registration. But a **non-empty** deps array is not automatically safe:
  `useFrontendTool` keys its registration effect on `JSON.stringify(extraDeps)`
  (`use-frontend-tool.tsx:45`), so only deps that actually **serialize** —
  strings, numbers, plain objects — vary that key. A `Map`, a `Set` or a
  function stringifies to a constant regardless of its contents
  (`JSON.stringify([new Map(), () => {}])` is the fixed string `"[{},null]"`),
  so a deps array built from one is as INERT as an empty one: the tool
  registers once and its closure is stuck on whatever those values were at that
  first commit. Data reached through a Map, a Set or a stable callback belongs
  in a ref, read as `ref.current` inside the handler/render, not in the deps
  array. `src/skins/bookstore/tools.tsx`'s `openBook` is the worked ref-pattern
  example for a non-write tool (its `[]`-deps comment spells out why
  `[router, data.books, skinHref]` would never re-register); banking's
  `cardsRef` comment (`src/skins/banking/tools.tsx:130-136`, above
  `setCardPin`) is the original write-case version, and warns about the
  opposite trap too — a serializable `[cards]` dep there would tear the tool
  down and rebuild it mid-write.
- **A parameterized `useComponent` render receives the schema output DIRECTLY** —
  `render: ({ myParam }) => …`, NOT wrapped in `{ args }`. Per the installed
  types, `InferRenderProps<T> = T extends StandardSchemaV1 ? InferSchemaOutput<T>
: any` and `render: ComponentType<NoInfer<InferRenderProps<TSchema>>>`. By
  contrast `useHumanInTheLoop` and `useFrontendTool` renders DO receive `{ args,
status, respond }`. Airline has no parameterized `useComponent`, so don't learn
  the render shape from it — see the template and logistics' `showShipment`.
- **A gen-UI render's `parameters` schema is NOT enforced either, and a render-only
  tool has no way to report a bad argument back.** Same trap as a
  `sandboxFunction`'s schema (above), one degree worse. A `useComponent` render is
  handed `partialJSONParse(toolCall.function.arguments)` verbatim
  (`use-render-tool-call.tsx` in `@copilotkit/react-core`); the schema is only
  serialized into the tool definition the model reads. And because a
  render-only tool has no `handler`, core posts an EMPTY tool result
  (`executeSpecificTool` in `run-handler.ts`), so there is no string to correct the
  model with — the sandbox's "throw a message naming the accepted values" escape
  hatch does not exist here. So do BOTH: enumerate the parameter to its real
  domain (`z.enum(YOUR_CONST_TUPLE)`, which is what puts the vocabulary in front of
  the model), AND resolve it explicitly in the render, drawing a plain "there is no
  such X, the real ones are …" card instead of the visual. Commerce's
  `showMarginLadder` + `src/skins/commerce/category-argument.ts` is the worked
  example: with a free `z.string()` category, a model saying "Shoes" for
  "Footwear" draws the signature five-rail ladder with ZERO dots on it, and an
  empty view rendered confidently is the worst outcome available because it looks
  like an answer. Note the third
  state that module carries: arguments STREAM, so a value that is still a PREFIX of
  a real member is "not arrived yet", not a refusal — refuse it and you flash a red
  card on every call the demo makes. (Same beat-6 carve-out as above: a GATE's
  unlock codes are the one closed set you must leave un-enumerated —
  failure-modes.md § 10.)
- **EVERY argument is `undefined` mid-render, including the ones your schema
  declares REQUIRED.** The point above is about a value that arrived and was
  wrong; this one is about a value that has not arrived at all. A render runs from
  the first frame of its tool call, and `partialJSONParse` returns `{}` for those
  frames, so `.optional()` is not what makes a field absent and a required field
  is not what makes it present. Two different bugs come out of that and one guard
  fixes only one of them:
  - it **THROWS**: `orderIds.map(…)` / `list.length` / `id.replace(…)` on an
    argument that is still `undefined` is a TypeError **inside React render**.
    Guard the shape — banking's `showTable` is the reference (`columns ?? []`,
    `rows ?? []`, `src/skins/banking/tools.tsx:793-794`) — and remember the
    CONTENTS too: a half-streamed `["` parses to `[""]`.
  - it **LIES**: formatting an absent value into a confident label asserts a
    choice nobody made — a Sort chip reading "Sort · oldest first" over an unset
    lever (`src/skins/commerce/order-queue-levers.ts`), a red "nothing matches ''"
    before the needle arrives, beat 4's rose "why" band drawn as an empty coloured
    bar while the note streams. The fix is never a default — it is to render only
    what is known.

  And do not over-guard into silence: a 

…(truncated)
