The previous comment presented the effect-based ref sync as merely avoiding
the react-hooks/refs lint rule. It is a trade-off: the render reads
cardsRef.current, and an effect writes after commit, so a card mutation
landing while the PIN card is open can leave the picker one render behind.
Records why that is accepted and what fixing it properly would require.
Replays three generative-UI changes from the banking showcase (#6259) into
the reskinnable demo's banking skin:
- setCardPin keys its collapsed render on the tool result and remembers
answered calls in a module-scope map, so a resolved PIN change survives
re-render. Drops the [cards] dependency in favour of a ref, which is what
stopped the tool tearing down mid-flight.
- showCharges becomes human-in-the-loop: it asks before moving the user's
whole screen, and routes through the skin-scoped base prefix.
- Notes about reported charges are prefixed with a red alert emoji.
Replays the report chart curation from the banking showcase (#6259, #6263)
into the reskinnable demo's banking skin. The three columns become spend
breakdown, spend over time and largest charges — the last of which answers
"which line items drive the total", a question a three-team aggregate
cannot. Additions carry a policyId so their bars colour by owning team.
Ports the brand-tint treatment for explicitly-set filter selects from the
banking showcase charges page (#6259) into the reskinnable demo's banking
skin, so an agent-applied filter reads as deliberate.
Ports the seed rebalance from the banking showcase (#6259) into the
reskinnable demo's banking skin so the report's spend-share donut shows a
meaningful split rather than three near-equal slices.
Ports the ranked largest-charges chart from the banking showcase (#6263)
into the reskinnable demo's banking skin. The report-card wiring that
consumes it lands separately.
Ports the red-alert-emoji instruction from the banking showcase seeded
memories (#6259) into the reskinnable demo's banking skin, so recalled
memory drives the same note formatting.
Ports the "format prose the same way every time" instruction block from
the banking showcase agent prompt (#6259) into the reskinnable demo's
banking skin, where the prompt lives in agent.ts rather than the route.
Ports the confirm-before-navigate chat card from the banking showcase
(#6259) into the reskinnable demo's banking skin. The showCharges
human-in-the-loop wiring that consumes it lands separately.
Points this app at the stack's second seeded organization
(haus-von-haskell) instead of the one banking uses (casa-de-erlang). Org is
resolved from the authenticated cpk key, and seed.sql already provisions three
orgs for exactly this, so it is a key swap with no backend change.
Ports and orgs isolate on independent axes and the second one matters: ports are
a local convention that copying banking's .env over this one silently undoes,
whereas the org key still holds when someone does. Verified against a freshly
seeded stack — writing under one key is invisible to the other even for an
identical user id, so neither demo can read or delete the other's memories.
Also corrects an inherited claim. banking's .env.example warns that non-seeded
ids 403; measured, they do not — GET/POST /api/memories returns 200/201 for an
unseeded id and for a nonsense one, the scope being created on demand. That
mattered because DEMO_DEFAULT_USER_ID is absent from seed.sql, so the warning
implied the unpinned interactive config it recommends was broken. It is not.
This app and the sibling banking demo vendor the same durable-memory stack with
identical seeded ids and api key, so which backend a process attached to was
invisible — a `pnpm dev` pointed at the neighbour's live stack shared its memory
buckets silently, and the presenter reset could mutate the other demo's state.
Surface the target so a human notices:
- Runtime route logs the resolved Intelligence API URL once at startup when
memory is enabled ("[reskinnable-demo] Intelligence: <url> (memory enabled)").
Never logs the api key or license token.
- The destructive presenter reset now names its target: it logs a warning with
the apiUrl and the exact user ids before forgetting, and includes the resolved
apiUrl in both the success and error response bodies so the caller can see
which backend was mutated. Behaviour is otherwise unchanged (same ids forgotten,
same PRESENTER_RESET_ENABLED gate, no confirmation prompt); only observability
improves. route.test.ts updated to assert the new apiUrl field, still exact.
reskinnable-demo was cloned from examples/showcases/banking and vendors an
identical Intelligence (durable-memory) docker stack — same seeded ids
(jordan-beamson / morgan-fluxx / northwind-demo-user), same INTELLIGENCE_API_KEY
and org. Both apps identified their backend purely by address, and both pointed
at http://localhost:7050. So with banking's stack already up, a bare `pnpm dev`
here (which, unlike run-demo.sh, checks no port bind) would silently attach to
banking's backend and read/write the SAME memory buckets — worst case, the
presenter reset button forgetting the neighbour demo's memories.
Shift every published host port by +200 (705x/715x -> 725x/727x) in lockstep
across compose, env, scripts, e2e config and tests so attaching to the wrong
stack is impossible by accident:
app-api 7050 -> 7250
gateway 7053 -> 7253
postgres 7156 -> 7256
redis 7158 -> 7258
minio API 7160 -> 7260
minio console 7161 -> 7261
bundled TEI 7167 -> 7267
The native Metal TEI on :7067 is left shared ON PURPOSE: it holds no demo state
(buckets live in the now-isolated postgres/redis), the same TEI version + model
yields byte-identical embeddings, and run-demo.sh reuses it when healthy rather
than forcing a second ~20x-slower model load. A comment records why.
The report's third column was budget-usage bars: the same three team totals the
donut beside it already showed, redrawn with limits added. Two charts, one fact.
It now ranks the largest individual charges, which changes the unit of analysis
from team to transaction. A team aggregate cannot distinguish one $15,000
charge from thirty $500 ones, so this is information the donut genuinely cannot
carry. Bars stay coloured by owning team, so a row still ties back to its slice
without the two charts duplicating each other.
Budget-vs-limit is not lost: it remains the "Over policy limit" KPI above and
the "Needs a decision" rows below.
Invoice-derived line items now carry the policy id of the team they belong to,
so they colour like any other charge. Without it they fell back to a generic
swatch and a Marketing charge did not match Marketing's slice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Points the demo runner, smoke scripts and env template at the skin-scoped
routes and relocated modules, and gives the vendored Intelligence stack its own
compose project name.
That last one matters: cloned from banking, this app inherited the same project
name, so both stacks resolved to one Docker project sharing containers and
named volumes — a compose down in either destroyed the other's memory. The
smoke scripts also still posted to /agent/default/run, which no longer exists
now that agents are keyed per skin; nothing in the toolchain reads those files,
so no build or test could have caught it.
Describes the app that exists: shell versus skins, the Skin contract field by
field, the client/server boundary that keeps a skin's agent out of the browser
bundle, per-skin server identity, and the theming split. Adds a repo-local
authoring skill that walks someone through creating a skin and registering it
in both registries.
The inherited docs described a single-skin banking demo, which was actively
misleading once a second skin shipped, and carried claims that were false here
— that OGUI can only render inline in the chat (a published-SDK limitation
that does not apply on workspace packages) and that the airline skin omits
every optional contract slot when it ships nine tool labels.
Retargets the e2e suite onto skin-scoped routes and covers the app's headline
feature for the first time: the index redirect, each skin rendering its own
chrome, an unknown skin 404ing, and switching in both directions.
Those assertions check content only a mounted skin can produce. An earlier
draft matched on brand text, which the shell's skin selector renders on every
page for every skin — so it passed against a deliberately broken airline. Each
assertion here was verified by breaking the app and watching it fail.
ogui-routing is marked fixme: it clicks seven suggestion pills, six of which
were dropped from the registered set before this change, so it has been
failing on main unnoticed — nothing runs this suite in CI.
/[skin] resolves a skin from the URL and mounts it keyed by its id, so
switching fully remounts into a fresh world; / redirects to the default skin.
The runtime route registers one agent per skin and resolves end-user identity
through that skin's resolver, falling back to the default skin for app-level
routes such as the inspector's memory endpoints.
globals.css now defines only the design-token vocabulary plus a brand-neutral
default set; each skin supplies its own values through a .theme-<id> block, so
a reskin is a pure value swap and the shared chrome styles itself with
semantic utilities.
A second skin — identity, theme, app-shell, three pages, ten presentational
components, frontend tools, a2ui catalog, suggestions and an OGUI design
brief — sharing nothing with banking beyond the contract and a few primitives.
It backs its data with plain client state rather than a REST API and
contributes no identity resolver, so the contract is exercised across both
substrates. A contract that only ever hosted one kind of skin proves much
less than one that spans two.
Banking's domain types, in-memory store, seed data, REST backend, components,
gen-UI beats, a2ui catalog, OGUI sandbox, pages, tools and agent now live
entirely under src/skins/banking, and its backend is namespaced to
/api/banking/v1 rather than mounted at the app root.
Every route under it is banking domain — cards, transactions, exceptions,
policies, reports, users, dev/reset — so with a second skin sharing the app,
leaving one skin's whole backend at the root contradicts the premise that
skins are self-contained. Shared UI primitives stay in src/components/ui,
which the shell and both skins import.
Banking contributes its identity through the contract (RuntimeProviders +
useRuntimeProperties) so the CopilotKit provider owns the active member from
its first commit, and a server-safe resolver so Intelligence scopes memory to
the selected persona.
The shell owns everything independent of which skin is mounted: the Skin
contract, a provider that runs a skin's data hook, split client/server
registries, the chat surface, the a2ui/OGUI canvas region and thread
selection.
The client/server split is deliberate. A skin's agent pulls in
@copilotkit/runtime, which must never reach the browser bundle, so agents are
registered in a separate server-only registry and linked to their skin by a
shared id rather than by being part of the client contract. Skins may also
contribute a server-safe identity resolver there, which is what lets per-user
durable memory be scoped per skin instead of the runtime hard-coding one.
Registers examples/showcases/reskinnable-demo as its own pnpm workspace member
resolving @copilotkit/* via workspace:*, alongside the other showcases.
No dependency differs from the banking showcase this app started as: the
standalone prototype's recharts dep is unused (banking hand-rolls its SVG
charts), and the airline skin's lucide icons all exist in the pinned range.
Five presentation fixes to the Northwind Finance demo, all from running the
beats live.
PIN change now resolves into a card rather than the sentence "New PIN saved."
It shows the card face, brand and last4, a masked new-PIN row and an active
badge. Digits are never rendered: they are never sent to the agent, so the mask
is the honest representation.
Reopening a thread replays setCardPin with status "inProgress" and no result,
so the answered card sat on "Loading..." forever. Other human-in-the-loop tools
here (showCharges) do replay their result, so this is specific to that call.
The outcome is now remembered per tool call id for the session and consulted
ahead of the replayed status. Both this card and the charges card key their
resolved state on the RESULT rather than the status, so an answered call can
never replay with live buttons.
setCardPin also registers once via a ref instead of depending on `cards`:
useFrontendTool re-registers whenever JSON.stringify(deps) changes and
re-registration removes the tool, so the PIN write tore down the very tool that
was servicing it.
showCharges becomes human-in-the-loop. Opening a filtered list is safe, but it
replaces the whole screen, and an agent that does that unasked reads as the
agent being in charge. The confirm card names the sort and filters before the
page changes, and on arrival the Sort and Show controls carry the brand tint
whenever they are non-default, so what the agent set is what lights up.
The Q2 report shows three different chart forms (share-of-total pie, time
series, budget bars) instead of three bar charts, and the seed is rebalanced so
team shares read 42/28/30 instead of 98/2/2 while all three pending charges
still exceed their limits. This drops the income-vs-expenses chart that was
showing $0.00.
Notes about reported charges carry a leading alert emoji so they cannot be
skimmed past. The seeded procedure asks for it and the handler applies it
regardless, because a model is not a reliable emoji emitter.
Finally, prose answers get a house style. The agent was formatting the first
few bullets of a list and then lapsing into plain text, which reads as a
rendering bug rather than a style choice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The approval loop could never pass, independent of the demo's behavior:
for (let step = 0; step < 4; step++) {
const approve = page.getByRole("button", { name: /^approve$/i });
await approve.first().click({ timeout: 30_000 }).catch(...)
}
Two compounding problems. The recall path emits at most three HITL cards
(openPolicyException -> finalizePolicyException -> approveTransaction) but the
loop demanded four, so the last iteration always waited on a card that never
arrives. And the per-click timeout (30s) equalled the whole test budget
(playwright.config.ts sets no `timeout`, so Playwright's 30s default applied),
so the first miss consumed the entire test and the `.catch()` fallback to the
other APPROVE_LABELS was unreachable dead code.
Now: poll every label for the next visible approve control, treat "no card
left" as flow completion rather than a timeout, keep a runaway guard that is
explicitly not the expected card count, and give the test a budget that can
actually hold one agent turn plus the closing server poll.
Confirmed against a local run (aimock + the docker Intelligence stack): the
agent recalls the procedure, files the EXC-BOARD-APPROVED exception, and
approves the charge, and the "Record a workflow?" card never appears. The
header's "verify on first green run" notes are updated to record what that
run settled and what it did not.
The spec still fails at its closing assertion: PUT /api/v1/transactions/t-3
{status:"approved"} keeps returning 422, i.e. store.hasApprovedException() is
false, so the over-limit gate is never lifted even though the (fixture-scripted)
agent text claims it was. That is pre-existing and out of scope here — this PR
touches no part of the approval path (lib/store, the exceptions/transactions
routes, copilot-context, components/wow are all untouched). Left failing rather
than skipped so it stays visible.
Co-Authored-By: Claude <noreply@anthropic.com>
Realigns the inspector/memory work onto the banking demo as it shipped in
#6136 (ChatGPT-style shell, gen-UI beats, durable-memory self-learning) and
#6202 (README refresh).
All six conflicts were the same collision: this branch removes the bespoke
Glass Engine inspector, while #6136 kept and rebuilt around it.
- run-handler.ts: kept both sides (our CopilotKitCoreCatalogComponent and
main's MAX_FOLLOW_UP_DEPTH landed at the same spot).
- wrapper.tsx / layout.tsx: took main's rewritten provider tree and
right-hand icon rail, minus the Glass Engine providers, pane, and
telescope toggle. Also dropped main's `padClass` (it reserved space for
the Glass pane and referenced a now-removed `glassActive`) and
`<ProactiveNotice />` (main removed it; the import is already gone).
- memory-tab.tsx, lib/intelligence/memory.ts: confirmed the deletions.
Their only remaining importers were the bespoke inspector and the
banking-local /api/memories routes, all removed here. seed-memories.ts
is unaffected: it POSTs to INTELLIGENCE_API_URL, not the local route.
- README.md: kept our product-inspector section over main's Glass Engine
availability/activation prose, and documented the Capabilities tab.
Drive-by fixes to comment rot the migration created: user-id.ts and the
copilotkit route doc comments referenced the deleted Memory-panel proxies,
and the README pointed the presenter-reset control at the removed
telescope toggle.
Also replaces a literal NUL byte in capabilityKey() with a unicode escape.
The raw control character made tsc/grep/diff treat run-handler.ts as a
binary file, which hid this very merge's conflict markers from grep.
Behavior is unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
#6136 changed the demo substantially and left the README describing an older
build. Corrections:
- It documented CopilotPopup; the chat is a left-docked CopilotSidebar rendering
two columns (thread rail + conversation) with the app's nav rail on the right.
- It called the learned over-limit memory `procedural`; the code writes
`operational`.
- It said the Glass Engine Memory tab was "top-k semantic recall, not a full
enumeration". It is now a complete enumeration ("Stored memories (N)"),
aggregated across the demo's identities, with semantic search as a separate
box.
- It listed per-run reset as a future follow-up. It ships, gated behind
PRESENTER_RESET_ENABLED, and also re-seeds memory and returns to the bare root.
- It described showTransactions as a useFrontendTool; it is a useComponent so it
persists in the transcript.
- The "change my Visa PIN from the team page" example described a navigation
fallback; PIN entry is now a component in the chat and the agent never sees the
digits.
Adds what was missing: the eight-pill demo script, the /charges page, the PDF/Q2
multimodal beat, seeded memory (and why the over-limit procedure is deliberately
NOT seeded), the components-not-markdown-tables rule, per-team chart colour, and
the two incompatible helper families in actions.ts that make destructuring `ok`
report a success as a failure.
All three screenshots are replaced — they showed the old right-docked chat, so
they contradicted the first paragraph.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turns the Northwind Finance banking demo into a presentable end-to-end demo.
Every beat was driven live and verified against real app state, not just the
agent's own claims.
The demo is eight suggestion pills, in order: show a chart; change a card PIN in
an interactive in-chat component; ask what is on screen; open the 10 most
expensive charges; prep the Q2 report from an attached PDF invoice; summarize
spend using a remembered preference; handle an unrecognized charge using a
remembered procedure; and finally learn a brand-new procedure by watching the
officer clear an over-limit charge. The last three are sequenced deliberately:
already-knows-a-preference, already-knows-a-procedure, learns-a-new-one — and on
a fresh thread the newly learned procedure is recalled and applied to a
different charge unaided.
Shell and UI:
- Chat docks LEFT as two columns (thread rail + conversation) with the app's
icon rail moved right via flex `order`, so nav still precedes <main> in the
DOM. The chat surface runs ChatGPT's neutral palette while the app keeps its
violet identity.
- Tool activity renders as ChatGPT-style expandable lines; internal AG-UI
plumbing is filtered out of the transcript.
- Tabular data always renders as components, never markdown tables; a one-row
table is blocked structurally because a single-record table is narration.
- Filed reports are a dashboard (KPI tiles, three charts, decision rows) rather
than a memo, with one colour per team across every chart and ranked bars in
place of a donut that collapsed when an invoice pushed one team to 96%.
- PIN entry happens in the chat component; the agent never asks for or receives
the digits.
Fixes worth review attention:
- Anything a RECALLED procedure can call must be registered globally. Four
action tools were registered on the cards route, so a saved procedure only
worked from one page.
- An unanswered human-in-the-loop card poisons the thread: skipping it left an
unresolved tool call and the next message failed with "Tool result is missing
for tool call ...". The procedure's closing step no longer gates; approvals
that move money still do.
- actions.ts has two helper families that are not interchangeable —
openPolicyException / finalizePolicyException / changeTransactionStatus return
{ok, data/error}, while changePin / addNoteToTransaction resolve with the
updated record. Destructuring `ok` off the second family reports success as
failure.
- A stale canvas surface hid the page: it is derived from the last a2ui-surface
message and replaces the page body until dismissed, but the dismiss effect
keyed on pathname only, so a ?tab= switch left it covering the page.
- Suggestion pills ignore clicks while a run is in flight, since a double-click
orphaned the previous run's tool call.
Deliberately NOT included: the @copilotkit/license-verifier pnpm patch (local
workaround for the "Invalid license token" banner) stays uncommitted, because it
patches a dependency for every install and is not demo scope.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the stale doc-comment reference to the deleted src/lib/glass-engine.ts in
presenter.ts (final verification sweep).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the GLASS_ENGINE_AVAILABLE env block from .env.example and replace the
README's 'Advanced mode — Glass Engine' section with a note on the product
web-inspector (enabled via showDevConsole; Memory tab powered by
exposeMemoryRoutes in Intelligence mode).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delete the final e2e test that drove the bespoke Glass Engine pane and remove
the GLASS_ENGINE_AVAILABLE env from both playwright configs. Self-learning
recall stays covered by the headless recall test; the product web-inspector is
covered by packages/web-inspector's own tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delete the 13 Glass Engine inspector files (pane, tabs, event store, event-card
mapping, deployment gate, env gate) and their tests, the dead Next /api/memories
proxy routes, and the now-unused src/lib/intelligence/memory.ts helper module
(+ test). Memory now flows through the product path (core memory store →
runtime /api/copilotkit/memories) rather than the banking-local Next proxies.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop useGlassEngine(), the Telescope toggle button, and the glassActive-driven
body padding from the app layout. The product inspector launcher replaces the
telescope toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the GlassEngineProvider / InspectorStoreProvider wrappers and the
<InspectorPane/> mount from the client tree, and drop the glassAvailable prop
(and its server-side threading in layout.tsx). The product web-inspector
enabled in the prior commit replaces the bespoke pane.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Set showDevConsole={true} on the CopilotKitProvider to surface the product
web-inspector on every host, and set exposeMemoryRoutes: true on the
Intelligence-mode CopilotRuntime so the inspector's Memory tab can list and
recall memories in the demo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The .first() guard on the 'rendered on the canvas' handoff-pill assertion
was justified by an inaccurate comment (accumulation across exchanges). The
real cause is intra-turn: generateSandboxedUi has followUp:true, so aimock
re-serves the same fixture on the unchanged-userMessage follow-up turn in
replay -> a second identical pill. A terminating sequenceIndex follow-up
fixture was attempted but destabilized the suite (title-generation requests
substring-match the pill text and consume the sequence counter before the
real leg-1 turn), so the .first() guard remains. Test/fixtures only; no
source changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>