chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
# Required (OSS + Intelligence modes)
OPENAI_API_KEY=
# ── License (unlocks paid Intelligence features such as durable memory) ──────
# COPILOTKIT_LICENSE_TOKEN is required for either path below; pick ONE.
#
# MANAGED Intelligence (hosted — the eventual target for this demo):
# Use a CopilotKit-ISSUED token (your account team, or `copilotkit license
# -n banking-demo`) and point the INTELLIGENCE_* endpoints below at the managed
# stack. Do NOT set BAKED_LICENSE_KEYS_JSON — managed/official images bake the
# master public key as the root of trust and ignore a runtime baked key.
#
# SELF-HOSTED local dev (current): a locally-built Intelligence stack gates
# memory behind a signed offline license. Generate one with
# `pnpm mint-dev-license --write` (needs the private Intelligence source; see
# scripts/mint-dev-license.mjs) — it fills in the three vars below for you.
COPILOTKIT_LICENSE_TOKEN=
# Self-hosted only — the trusted key that signed the dev license above.
# Leave UNSET for managed Intelligence.
# BAKED_LICENSE_KEYS_JSON=
# Self-hosted only — main renamed the deployment-mode env (underscore value).
# INTELLIGENCE_DEPLOYMENT_MODE=self_hosted
# Intelligence (memory) mode — set all three to enable durable cross-thread learning.
# Ports/key match the vendored docker-compose.yml (see docs/superpowers/specs/2026-06-26-stack-decision.md).
INTELLIGENCE_API_URL=http://localhost:7050
INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:7053
INTELLIGENCE_API_KEY=cpk_sPRVSEED_seed0privat0longtoken00
# Leave INTELLIGENCE_USER_ID UNPINNED for the interactive demo so the sidebar
# user switcher drives memory scope (Alex -> jordan-beamson, Maya -> morgan-fluxx;
# see src/lib/intelligence/user-id.ts). Pin it only for a single-identity run
# (CI/e2e already pins it in playwright.config.ts). Both survivors must map to a
# SEEDED backend id — non-seeded ids 403 against the Intelligence stack.
# INTELLIGENCE_USER_ID=jordan-beamson
# INTELLIGENCE_USER_NAME=Jordan Beamson
# Glass Engine (advanced inspector). Unset = absent (public-host default).
# Set to "true" for FDE/sales/conference deployments to expose the left-rail
# telescope toggle. Distinct from the presenter's per-session on/off toggle.
GLASS_ENGINE_AVAILABLE=
# Presenter/booth reset button (left sidebar). Unset = hidden AND the
# /api/v1/dev/reset endpoint is disabled (403). Set to "true" for
# FDE/sales/conference deployments so a presenter can reset demo state
# (re-seed transactions + forget all durable memories) without curl.
PRESENTER_RESET_ENABLED=
# Test-only: point the agent LLM at aimock for the deterministic E2E proof.
# Leave unset in normal runs. (See e2e/memory-learning.spec.ts.)
# OPENAI_BASE_URL=http://localhost:7099/v1
+15
View File
@@ -0,0 +1,15 @@
node_modules/
.vscode/
.idea/
.env
.DS_Store
.next
# TypeScript incremental build cache
tsconfig.tsbuildinfo
# Playwright
test-results/
playwright-report/
playwright/.cache/
+326
View File
@@ -0,0 +1,326 @@
# Northwind Finance — CopilotKit v2 Banking Demo
A customer-ready reference demo showing how to build a SaaS app with an embedded
AI copilot on top of CopilotKit v2. The app — "Northwind Finance" — models a
corporate banking dashboard where role-based users can view transactions,
manage credit cards, and (for admins) manage team members. The copilot is
wired into the same UI: it reads app context, calls typed tools to render
generative UI, and asks the user to approve sensitive actions via
human-in-the-loop.
## Screenshots
| | |
| ----------------------------------------------------------- | ---------------------------------------------- |
| ![Northwind Finance dashboard](assets/aurora-dashboard.png) | ![Copilot chat panel](assets/copilot-chat.png) |
![Learning mode — the recording vignette pulses while the copilot records a demonstrated officer action](assets/learning-mode-vignette.png)
While the officer demonstrates an action the copilot should learn from
(approving a transaction, filing a policy exception), a soft violet vignette
pulses around the canvas — the visible signal that the action is being
recorded for the self-learning loop.
## Running locally
```bash
export OPENAI_API_KEY=your-key
pnpm install # from the repo root — this demo is a workspace package
pnpm --filter demo-saas-copilot dev
```
Then open <http://localhost:3000>.
The demo runs against the workspace versions of `@copilotkit/*` (see the root
`pnpm-workspace.yaml`). The seed dataset lives in memory and resets every time
the server restarts.
## Memory & durable self-learning (Intelligence mode)
By default the runtime is pure OSS: an SSE `CopilotRuntime` + `InMemoryAgentRunner`,
with no external dependency. The agent runs locally against OpenAI and nothing is
persisted. **This is the default and requires only `OPENAI_API_KEY`.**
The runtime in `src/app/api/copilotkit/[[...slug]]/route.ts` is **env-gated**: when
the three `INTELLIGENCE_*` vars below are all present it builds the runtime in
Intelligence mode (`CopilotKitIntelligence` + `CopilotRuntime({ intelligence,
identifyUser, licenseToken, … })`). The local `bankingAgent` still executes here,
but it gains durable long-term memory — the `recall_memory` / `save_memory` MCP
tools auto-attach from the memory-enabled Intelligence backend. If any of the three
is unset, the demo falls back to the exact OSS path above.
### What each mode recalls
- **OSS (default):** the teach-a-workflow loop works _within a single
conversation_. Start a **new** thread and the agent no longer knows the
procedure; nothing persists across threads or restarts. (Expected — it's the
signal that durable recall needs Intelligence mode.)
- **Intelligence:** durable long-term memory across three flavours, all via
`save_memory` / `recall_memory`:
- **Demonstrated over-limit procedure** — saved as a `project`-scoped,
`procedural` memory and recalled at the start of any later over-limit
request. A **brand-new thread — or a different user on the same team**
recalls the procedure and completes the approval unaided. This is the
durable cross-thread + cross-user proof (FOR-149).
- **General facts / preferences (`user` scope)** — arbitrary personal facts
persist cross-thread but stay per-person. Try _"remember my favorite food
is sushi"_, then ask _"what's my favorite food?"_ in a **new** thread and
the copilot recalls it.
- **Team-shared facts (`project` scope)** — facts flagged for the whole team
persist cross-user, so a teammate recalls them in their own threads.
- **Secrets are never stored.** Passwords, API keys, tokens, and full card or
SSN numbers are never written to memory.
### 1. Start the memory-enabled stack (one command)
A self-contained Intelligence stack (postgres + pgvector, redis, minio, a TEI
embedder, and the `app-api` + realtime-gateway composite) is vendored as
`docker-compose.yml`:
**Recommended (one command, handles the embedder per-platform):**
```bash
cd examples/showcases/banking
export INTELLIGENCE_REPO=/path/to/Intelligence # composite image build context + dev-license signer
./run-demo.sh
```
`run-demo.sh` brings up the stack, picks the right embedder for your platform
(native Metal TEI on Apple Silicon, the bundled docker `tei` on amd64/CI), mints
a dev license if `.env` lacks one, then starts the Next.js dev server.
**Manual (if you prefer raw compose):** the bundled `tei` is gated behind the
`cpu-fallback` profile, so a bare `docker compose up` **skips it** (see the Apple
Silicon note below). On amd64/CI, opt it in:
```bash
export INTELLIGENCE_REPO=/path/to/Intelligence
docker compose --profile cpu-fallback up -d --wait # amd64/CI: bundled docker embedder
pnpm dev
```
Host ports: app-api **7050**, gateway **7053**, postgres 7156, redis 7158, minio
7160/7161, tei 7167. (The deps use a `715x` range so a bare `docker compose up`
coexists with a developer's own Intelligence dev stack on `705x`.) Seeded org
`casa-de-erlang`, key `cpk_sPRVSEED_seed0privat0longtoken00`, users
`jordan-beamson` / `morgan-fluxx`. The team is exactly two members, each mapped
1:1 to a seeded backend identity — **Alex Morgan (Admin) → `jordan-beamson`** and
**Maya Chen (Assistant) → `morgan-fluxx`** (see `src/lib/intelligence/user-id.ts`).
That 1:1 mapping is what makes cross-user memory scope demonstrable through the
sidebar user switcher. `SL_ENABLED=true` + a reachable embedder are
required for the `save_memory`/`recall_memory` MCP tools to attach — both are set
on the `intelligence` service in `docker-compose.yml`.
> **Apple Silicon:** the bundled `tei` image is amd64-only. Under emulation the
> Candle/safetensors backend is unavailable, so TEI falls back to the ONNX/ORT
> backend — which needs `onnx/model.onnx` files that `Qwen3-Embedding-0.6B` does
> not publish (404), so the container **crash-loops**. `run-demo.sh` handles this
> for you (native Metal TEI on `:7067`). To do it manually, run a native TEI on
> the host and point the stack at it (the bundled `tei` is profile-gated, so a
> bare `up` already skips it):
>
> ```bash
> brew install text-embeddings-inference # one-time
> text-embeddings-router --model-id Qwen/Qwen3-Embedding-0.6B --port 7067 --auto-truncate &
> MEMORY_EMBEDDINGS_URL=http://host.docker.internal:7067 docker compose up -d --wait
> ```
>
> Same TEI version (1.9.3) + model as the docker image → **byte-identical
> embeddings**, and ~20× faster (Metal GPU vs CPU-under-emulation).
### 2. Point the demo at the stack
```bash
cp .env.example .env # fill OPENAI_API_KEY; run `copilotkit license -n banking-demo`
# .env (key lines):
# INTELLIGENCE_API_URL=http://localhost:7050
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:7053
# INTELLIGENCE_API_KEY=cpk_sPRVSEED_seed0privat0longtoken00
# # INTELLIGENCE_USER_ID — leave UNPINNED for the interactive demo (see below)
pnpm --filter demo-saas-copilot dev
```
The Next.js app needs only the three `INTELLIGENCE_*` vars (+ identity). The memory
backend flags (`MEMORY_ENABLED`, `SL_ENABLED`, `MEMORY_EMBEDDINGS_URL`,
`MEMORY_EMBEDDING_MODEL`) live on the `intelligence` service in `docker-compose.yml`.
Leave `INTELLIGENCE_USER_ID` **unpinned** for the interactive demo: with it unset,
the sidebar user switcher drives which backend identity (and therefore which memory
scope) is active, so you can walk through cross-user isolation live. It is pinned to
a single identity only for CI/e2e (see `playwright.config.ts`).
### 3. The cross-thread payoff (FOR-149)
With the stack up and project memory empty:
1. **Thread A — teach.** Ask to approve an over-limit charge. The agent calls
`recall_memory`, finds nothing, and offers to record. Demonstrate the policy
exception on the dashboard, then click **Save workflow** — the agent calls
`save_memory` (`scope:"project"`, `kind:"procedural"`).
2. **Thread B — recall.** Open a **new** thread and ask to approve a _different_
over-limit charge. The agent calls `recall_memory`, gets the procedure, files
the exception with the learned code, and approves — **with no recording offer.**
3. **Different persona.** Switch user (sidebar avatar) in a fresh thread and repeat
— same unaided success, proving `project`-scope cross-user recall.
### 4. The memory-scope isolation demo (two personas)
The team is exactly **Alex Morgan (Admin)** and **Maya Chen (Assistant)**, mapped
1:1 to the seeded `jordan-beamson` / `morgan-fluxx` backend identities. With
`INTELLIGENCE_USER_ID` unpinned, the sidebar user switcher (bottom-left avatar)
selects which identity is live, so scope isolation is visible end-to-end:
1. **Personal fact — save (as Alex).** Ask _"remember my favorite food is
sushi."_ The copilot confirms it saved (`user` scope).
2. **Personal fact — recall (as Alex, new thread).** Open a **new** thread and
ask _"what's my favorite food?"_ — the copilot recalls **sushi** (cross-thread,
same person).
3. **Personal fact — isolated (switch to Maya, fresh thread).** Switch the sidebar
user to Maya, open a fresh thread, and ask _"what's my favorite food?"_ — the
copilot **does NOT know it.** `user`-scope memory is per-person.
4. **Team fact — crosses users.** Back as Alex, say _"keep in mind, for the whole
team: our fiscal year ends in March."_ Switch to Maya and ask _"when does our
fiscal year end?"_ — the copilot recalls **March.** `project`-scope memory
crosses users on the same team.
To replay the "fails first" beat, forget the saved procedure between runs
(`DELETE http://localhost:7050/api/memories/:id`, or via the agent's
`forget_memory` tool). Per-run reset for a repeatable public demo is a separate
follow-up (user-scope memory, periodic DB reset, or a dashboard control).
### Testing
- **Deterministic E2E (CI gate):** `pnpm --filter demo-saas-copilot test:self-learning`
runs `e2e/memory-learning.spec.ts`. The agent's LLM is served by
[`@copilotkit/aimock`](https://github.com/CopilotKit/aimock) (fixtured
`save_memory`/`recall_memory` tool calls) while the **real** local memory backend
persists + recalls — so the full teach→save→fresh-thread-recall→unlock flow is
deterministic. It asserts the fresh thread completes the unlock from recalled
memory and never offers to record.
- **Real-LLM drift smoke (manual, non-gating):**
`node scripts/memory-drift-smoke.mjs` seeds the procedure via REST, then drives a
fresh-thread over-limit request against a real OpenAI key and asserts the live
model still emits `recall_memory` (the autonomous recall-first moment). aimock
fixtures replay a fixed decision and cannot catch _behavioral drift_ after a
prompt edit — this can. The save half is HITL-gated (not headless), so verify it
via the manual walkthrough above + the aimock E2E. Run after editing the prompt or
teach tools.
### Advanced mode — Glass Engine
Glass Engine is a docked inspector (desktop-only) that exposes the copilot's
internals. It is gated twice:
- **Availability (deployment):** set `GLASS_ENGINE_AVAILABLE=true` to expose the
left-rail telescope toggle. Leave it unset on public deployments — Glass Engine
is then absent entirely and the `/api/memories*` routes return 404. (FDE/sales/
conference deployments set it; one image, per-deployment env.)
- **Activation (presenter):** when available, the telescope toggles the pane on/off
per session (persisted in localStorage), so you can reveal it case-by-case during
a talk.
Tabs:
- **Timeline** — every AG-UI protocol event of each run, live (works in any mode).
- **Memory** — durable memory recall + semantic search (Intelligence mode only).
The "Recalled memories" list is top-k semantic recall, not a full enumeration.
- **Learning** — the over-limit teach→save→recall procedure and live recall
activity (Intelligence mode only).
In OSS mode (no `INTELLIGENCE_*`) the Memory and Learning tabs show a "Requires
Intelligence mode" hint.
## Architecture at a glance
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (Next.js 16, React 19, Tailwind v4) │
│ CopilotKitProvider + CopilotPopup (@copilotkit/react-core/v2) │
│ ├── useAgentContext → share user / page state with agent │
│ ├── useFrontendTool → generative UI (showTransactions) │
│ └── useHumanInTheLoop → approval flows (addNewCard, …) │
└─────────────────────────────┬───────────────────────────────────┘
│ AG-UI over SSE
┌─────────────────────────────────────────────────────────────────┐
│ Runtime (Hono, same Next process) │
│ src/app/api/copilotkit/[[...slug]]/route.ts │
│ BuiltInAgent + CopilotRuntime + createCopilotHonoHandler │
│ (from @copilotkit/runtime/v2) │
│ env-gated: OSS SSE + InMemoryAgentRunner by default; │
│ CopilotKitIntelligence when INTELLIGENCE_* env is set │
│ (see "Self-learning backend" below) │
└─────────────────────────────┬───────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Data layer │
│ src/data/seed.json → seed cards, team, policies, txns │
│ src/lib/store.ts → typed, in-memory store (resets) │
│ src/app/api/v1/* → REST surface │
│ (cards, transactions, │
│ users, policies) │
│ src/lib/identity.ts → Northwind branding strings │
└─────────────────────────────────────────────────────────────────┘
```
## Key features and where to find them
### App-wide context for the copilot
`src/components/copilot-context.tsx` shares the current user and the current
page with the agent via `useAgentContext`, so the LLM can adapt its responses
to the logged-in role and the route the user is on. The Northwind brand and
assistant greeting are centralized in `src/lib/identity.ts`.
Switch between users from the bottom-left avatar in the sidebar to see how
role (Admin vs Assistant) changes what the copilot will agree to do.
### Generative UI — `showTransactions`
The cards landing page at `src/app/page.tsx` registers
`useFrontendTool({ name: "showTransactions", render })`. When you ask the
copilot something like _"Show me transactions for my card ending 4242"_, the
LLM calls the tool and the rendered list IS the answer — there is no
follow-up paragraph restating the data.
### Human-in-the-loop — `addNewCard` and `navigateToPageAndPerform`
- `useHumanInTheLoop({ name: "addNewCard", render })` in `src/app/page.tsx`
shows the "add card" confirmation card directly in chat; the user clicks
Approve / Cancel and the result is sent back to the agent. The team page
(`src/app/team/page.tsx`) uses the same pattern for removing a member and
changing a member's role or team (inviting a member is a UI-only dialog
flow, not an agent tool).
- `useHumanInTheLoop({ name: "navigateToPageAndPerform" })` in
`src/components/copilot-context.tsx` is the cross-page fallback: if the user
asks for an operation that lives on another page (e.g. "change my Visa PIN"
from the team page), the copilot asks for permission to navigate, then
redirects with an `?operation=…` query param so the destination page can
open the right dialog.
### Role-based behaviour
Authorization is communicated to the agent through `useAgentContext` rather
than enforced on the LLM by prompt alone. The REST handlers in
`src/app/api/v1/*` enforce the same rules on the server side, so a curious
user (or a hallucinating model) cannot bypass them.
## Backend & data
- All read/write goes through `src/lib/store.ts`, which exposes typed helpers
— readers like `cards()`, `team()`, `policies()`, `transactions()` and
mutators like `findCard`, `updateCardPin`, `assignPolicyToCard`,
`updateTransaction` — over an in-memory copy of `src/data/seed.json`.
- The REST endpoints under `src/app/api/v1/*` (cards, transactions, users,
policies) are thin handlers around the store and are what the UI uses.
- There is no database. State resets on every server restart — this keeps the
demo deterministic for screenshots, e2e tests, and customer walkthroughs.
## Tests
End-to-end Playwright smoke tests live under `e2e/` and can be run with:
```bash
pnpm --filter demo-saas-copilot test:e2e
```
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5bd11e83b0834a69671ce38efb29754b7a875856fcf84eb1dce6e41f7dbe3d7c
size 284064
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0f4c24ab7f7a2df4ae90026d7359073c09ad52b185bb29b02195c70265f66fdc
size 205487
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:48d83e0626c4047260ecf17ab096c70469134998367c72cb1b45267b0f191a74
size 269361
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5c4f9f91804201ca6ce73eee4e388be1fe3df8b1513103ec4b195aa4d9c7aed9
size 393611
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
@@ -0,0 +1,251 @@
# ============================================================================
# Banking demo — memory-enabled CopilotKit Intelligence stack.
#
# Vendored from the proven `memory-chat` local recipe in the Intelligence
# repo (docker-compose.deps.yml + docker-compose.yml + run-demo.sh). It stands
# up everything the durable cross-thread memory feature needs:
#
# postgres (pgvector) :7156 app DB + halfvec memory store
# redis :7158 session / realtime fan-out
# minio :7160 realtime-gateway event archive (S3 API)
# minio console :7161
# tei :7167 Qwen3-Embedding-0.6B embeddings sidecar
# intelligence :7050 app-api (REST /api/memories + gated /mcp)
# :7053 realtime-gateway (thread/conversation state)
#
# `intelligence` is the single composite image (Dockerfile.composite) that
# runs app-api + realtime-gateway + thread-culler + the db-migrations oneshot
# under s6-overlay. The MEMORY_ENABLED / SL_ENABLED gates are compiled into
# app-api, so the memory MCP tools and the /api/memories REST surface come
# from the same binary the demo will eventually ship as a standalone app.
#
# cd examples/showcases/banking
# docker compose up -d --wait
#
# The build context for the `intelligence` image is the Intelligence repo
# checkout (it is NOT vendored into this repo — its Dockerfile.composite does
# `COPY . .` over the whole Intelligence workspace). Point INTELLIGENCE_REPO
# at your local checkout; it defaults to the sibling layout used on the
# reference machine. Once built, the image is tagged `cpki/intelligence-composite`
# and reused on subsequent `up`s.
#
# Seeded by the app-db-migrations seed.sql (run by the composite's migrations
# oneshot before app-api starts):
# org casa-de-erlang project elixir4days
# key cpk_sPRVSEED_seed0privat0longtoken00
# users jordan-beamson / morgan-fluxx
# ============================================================================
name: banking-memory
services:
postgres:
image: pgvector/pgvector:0.8.2-pg16
ports:
# Banking-specific host-port range (715x) so a bare `docker compose up`
# coexists with a developer's Intelligence dev deps (which use 705x).
- "${POSTGRES_HOST_PORT:-7156}:5432"
environment:
POSTGRES_USER: intelligence
POSTGRES_PASSWORD: intelligence
POSTGRES_DB: postgres
volumes:
- postgres-data:/var/lib/postgresql/data
# Creates intelligence_app + intelligence_app_shadow on first boot
# (the migrations oneshot and app-api connect to intelligence_app).
- ./docker/app-postgres-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U intelligence -d intelligence_app"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "${REDIS_HOST_PORT:-7158}:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
ports:
- "${MINIO_HOST_PORT:-7160}:9000"
- "${MINIO_CONSOLE_HOST_PORT:-7161}:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
# One-shot: create the bucket the realtime-gateway archives events into.
# `mc ready local` in minio's healthcheck guarantees the server is up first,
# but the embedded Docker DNS resolver can briefly fail to resolve the `minio`
# service name at container start, so retry `mc alias set` until it resolves.
# The `$$` escapes compose interpolation so the container shell sees `$`.
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint:
- /bin/sh
- -c
- |
i=0
until mc alias set local http://minio:9000 minioadmin minioadmin; do
i=$$((i + 1))
if [ "$$i" -ge 30 ]; then echo 'minio unreachable after 30 tries' >&2; exit 1; fi
echo 'waiting for minio dns/health...'; sleep 2
done
mc mb --ignore-existing local/realtime-gateway-events
echo 'minio bucket ready'
restart: "no"
# OpenAI-compatible embeddings sidecar. The cpu-1.9.3 tag publishes a
# linux/amd64 manifest ONLY (no arm64 build), so on Apple Silicon Docker runs
# it under emulation, where the Candle/safetensors backend is unavailable and
# TEI falls back to the ONNX/ORT backend — which needs onnx/model.onnx files
# that Qwen3-Embedding-0.6B does not publish (404), so it crash-loops. On
# amd64/CI this is native and works.
#
# Therefore this service is gated behind the `cpu-fallback` profile: a bare
# `docker compose up` does NOT start it. Apple Silicon runs a native Metal TEI
# on the host instead (see run-demo.sh / README), pointing app-api at it via
# MEMORY_EMBEDDINGS_URL=http://host.docker.internal:7067 (same version 1.9.3,
# same model, byte-identical embeddings). On amd64/CI, opt back in with
# `docker compose --profile cpu-fallback up -d --wait`. `intelligence`'s
# dependency on tei is `required: false`, so it starts fine without it.
tei:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9.3
profiles: ["cpu-fallback"]
platform: linux/amd64
# --auto-truncate is empirically required for the cpu-1.9.x image to serve
# Qwen3-Embedding-0.6B (max_input_length 32768) cleanly; truncation is the
# right behavior for memory content (capped at 8192 chars upstream).
command:
[
"--model-id",
"Qwen/Qwen3-Embedding-0.6B",
"--port",
"80",
"--auto-truncate",
"--max-batch-tokens",
"${TEI_MAX_BATCH_TOKENS:-16384}",
]
ports:
- "${TEI_HOST_PORT:-7167}:80"
volumes:
- tei-model-cache:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:80/health"]
interval: 10s
timeout: 5s
retries: 30
# First boot downloads the model and runs a warmup forward pass; on CPU
# (especially x86 under emulation) this can take several minutes, so give
# it a generous grace before counting failures.
start_period: 600s
restart: unless-stopped
# app-api (:4201 -> host 7050) + realtime-gateway (:4401 -> host 7053) +
# thread-culler + the db-migrations oneshot, all under s6-overlay. Built
# from the Intelligence repo's Dockerfile.composite (memory/SL gates are
# compiled in). The migrations oneshot runs graphile-migrate + seed.sql
# against postgres before app-api/gateway start, so the seeded org/key/users
# exist by the time the surface is healthy.
intelligence:
image: cpki/intelligence-composite:local
build:
context: ${INTELLIGENCE_REPO:-../../../../Intelligence}
dockerfile: Dockerfile.composite
ports:
- "${APP_API_HOST_PORT:-7050}:4201"
- "${GATEWAY_HOST_PORT:-7053}:4401"
environment:
DATABASE_URL: postgresql://intelligence:intelligence@postgres:5432/intelligence_app
REDIS_URL: redis://redis:6379
MEMORY_ENABLED: "true"
SL_ENABLED: "true" # REQUIRED — memory MCP tools attach by extending the SL /mcp server
# Embedder is pluggable. Default = the bundled `tei` container (self-contained,
# correct on amd64/CI/deploy). On a RAM-constrained Apple-Silicon dev box the
# emulated TEI can OOM (exit 137); override to a host/native embedder, e.g.
# MEMORY_EMBEDDINGS_URL=http://host.docker.internal:7067 docker compose up -d --wait \
# postgres redis minio minio-init intelligence
# (omits the bundled tei — its dependency below is required:false).
MEMORY_EMBEDDINGS_URL: ${MEMORY_EMBEDDINGS_URL:-http://tei:80}
MEMORY_EMBEDDING_MODEL: Qwen/Qwen3-Embedding-0.6B
# NOTE (main migration): main dropped the legacy DEFAULT_ORGANIZATION_ID.
# Org is resolved from the authenticated cpk key (seeded to casa-de-erlang);
# the header default falls back to 'self_hosted' when unset.
COPILOTKIT_LICENSE_TOKEN: "${COPILOTKIT_LICENSE_TOKEN:-}"
# main migration: self-hosted memory is gated behind a signed offline
# license carrying the `memory` feature (MEMORY_NOT_ENTITLED otherwise).
# BAKED_LICENSE_KEYS_JSON bakes the public key the verifier trusts, so a
# locally-minted dev enterprise license (scripts/mint-dev-license) unlocks
# memory without any master-key attestation. Dev-only local values.
BAKED_LICENSE_KEYS_JSON: "${BAKED_LICENSE_KEYS_JSON:-}"
# Auth / runtime secrets (exactly as in the reference run-demo.sh; the
# AUTH_SECRET must be >= 32 chars per auth-server's env schema). These
# are dev-only local values.
AUTH_SECRET: "local-dev-auth-secret-at-least-32-bytes-long-000"
AUTH_TRUST_HOST: "true"
# main renamed the deployment-mode env and uses an underscore value;
# the legacy `DEPLOYMENT_MODE=self-hosted` is rejected (crash-loop).
INTELLIGENCE_DEPLOYMENT_MODE: self_hosted
RUNNER_AUTH_SECRET: dev-runner-secret
SECRET_KEY_BASE: local-realtime-gateway-secret-key-base-at-least-64-bytes-long
PHX_HOST: localhost
# S3 (minio) wiring for the realtime-gateway event archive.
S3_ENDPOINT: http://minio:9000
S3_BUCKET: realtime-gateway-events
S3_ACCESS_KEY_ID: minioadmin
S3_SECRET_ACCESS_KEY: minioadmin
S3_REGION: us-east-1
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
tei:
condition: service_healthy
# Optional: when an external embedder is supplied via MEMORY_EMBEDDINGS_URL,
# bring the stack up without the bundled tei (`up ... intelligence` omitting tei).
required: false
healthcheck:
# app-api answers /api/health on 4201; gateway listens on 4401.
test:
[
"CMD-SHELL",
"curl -fsS http://127.0.0.1:4201/api/health && nc -z 127.0.0.1 4401",
]
interval: 10s
timeout: 5s
retries: 6
start_period: 90s
restart: unless-stopped
volumes:
postgres-data:
redis-data:
minio-data:
tei-model-cache:
@@ -0,0 +1,6 @@
-- Ported verbatim from Intelligence infra/app-postgres/init/01-create-databases.sql.
-- Runs once on the postgres container's first boot (docker-entrypoint-initdb.d).
-- The composite image's migrations oneshot + app-api connect to intelligence_app;
-- graphile-migrate uses intelligence_app_shadow for its shadow database.
CREATE DATABASE intelligence_app;
CREATE DATABASE intelligence_app_shadow;
+128
View File
@@ -0,0 +1,128 @@
# Northwind Finance — Design System ("Aurora")
A premium, airy, light-theme fintech aesthetic for the banking demo. This is a
**visual layer**: it restyles the existing app without changing logic, data
flow, REST calls, the CopilotKit provider/runtime, HITL approval flows, the
threads drawer, auth/role gating, or the teachable policy-exception gate.
The system is token-driven. Tokens live in
[`src/app/globals.css`](../src/app/globals.css) as CSS custom properties,
surfaced to Tailwind v4 via `@theme inline` so utilities like `bg-surface`,
`text-ink-muted`, `from-brand-violet`, `ring-brand`, `bg-positive` resolve
everywhere. Both **light** (the primary showcase mode) and **dark** are fully
supported via the class-based `.light` / `.dark` toggle on `<html>`.
## Design language
- **Mood** — premium, airy, modern. Generous whitespace, soft layered shadows,
large radii, glassmorphism-lite surfaces.
- **Canvas** — a soft lavender/lilac page background; content floats above it on
white glass cards.
- **Primary** — a violet→indigo gradient (`#7C5CFC``#5B3DF5`), reused on the
primary button, the active nav icon, the credit-card face, progress bars, and
CTAs (`.brand-gradient`).
- **Semantics** — income/positive is emerald green with an up-and-right arrow;
expense/negative is rose/red with a down-and-right arrow.
- **Typography** — Inter (loaded via `next/font/google`, exposed as
`--font-inter``--font-sans`). Very large bold balance numbers, small
muted-grey labels, medium-weight violet section headings (`.section-heading`).
- **Shape** — cards ~22px radius (`rounded-2xl` / `--radius`), buttons fully
rounded pills (`rounded-full`), inputs/menus ~1216px.
## Color tokens
All colors are HSL triplets behind `hsl(var(--token))`, exposed as Tailwind
colors (`brand`, `brand-violet`, `brand-indigo`, `brand-soft`, `surface`,
`surface-muted`, `canvas`, `ink`, `ink-muted`, `positive`, `positive-soft`,
`negative`, `negative-soft`, `hairline`).
| Token | Light | Dark | Role |
| ------------------- | ------------- | ------------- | --------------------------------------- |
| `--canvas` | `255 60% 97%` | `252 30% 7%` | App background (lavender / deep indigo) |
| `--surface` | `0 0% 100%` | `252 24% 11%` | Cards, sidebar, menus |
| `--surface-muted` | `252 40% 98%` | `252 22% 14%` | Row hover, inset blocks |
| `--ink` | `252 30% 14%` | `250 30% 96%` | Primary text / headings |
| `--ink-muted` | `250 12% 46%` | `250 12% 66%` | Secondary / label text |
| `--hairline` | `252 30% 92%` | `252 20% 22%` | Borders / dividers |
| `--brand`/`-violet` | `252 83% 67%` | (shared) | Primary violet |
| `--brand-indigo` | `248 84% 60%` | (shared) | Gradient end / heading color |
| `--brand-soft` | `252 90% 96%` | `252 50% 18%` | Lilac chips, hover wash, avatar bg |
| `--positive` | `152 62% 40%` | `152 56% 50%` | Income / available credit |
| `--positive-soft` | `152 70% 95%` | `152 40% 16%` | Income chip background |
| `--negative` | `349 78% 56%` | `349 80% 64%` | Expense / destructive |
| `--negative-soft` | `349 90% 96%` | `349 40% 18%` | Expense chip background |
## Radius, shadow, font scale
| Token | Value |
| --------------- | ---------------------------------------------- |
| `--radius` (lg) | `1.375rem` (~22px) — card baseline |
| `radius-xl/2xl` | `+6px` / `+12px` — sidebar, hero panels |
| `radius-md/sm` | `-6px` / `-10px` |
| `--shadow-soft` | resting card shadow (low, violet-tinted) |
| `--shadow-lift` | hover / floating panels / menus |
| `--shadow-glow` | violet glow under gradient CTAs |
| `--font-sans` | `var(--font-inter), ui-sans-serif, system-ui…` |
## Helper classes (in `@layer components`)
- `.brand-gradient` — the 135° violet→indigo gradient (buttons, card, CTAs).
- `.brand-text-gradient` — same gradient clipped to text.
- `.glass-surface` — translucent surface + backdrop blur (floating sidebar, cards).
- `.section-heading` — medium-weight violet/indigo section title.
## Layout
- **Floating icon rail** (`src/components/layout.tsx`) — ~72px, white glass,
rounded, soft-shadowed, detached from the screen edge. Brand mark at top; nav
icons (Dashboard / Credit Cards / Team) with a violet **gradient active
state**; theme toggle, user switcher, and a help "?" pinned at the bottom.
Role gating (Team only for admins) and the `useAgentContext` page readable are
unchanged.
- **Dashboard** (`src/app/dashboard/page.tsx`, mirrored by `/cards`) — two
columns on a lavender canvas:
- **Left**: "My Cards" (dashed add-card tile + a vivid gradient credit card,
with a second card peeking behind) and "Recent Transactions" with a
"View All" link, **underline** ALL / INCOME / EXPENSES tabs, a "TODAY"
chip, and transaction rows (circular tinted badge + title + subtitle +
colored amount).
- **Right rail**: a tall rounded panel — **Balance** (large bold) + masked
card number; an Income / Expenses split with colored arrows; a divider;
**Last Payment Details**; a **Statistics** sparkline; and a gradient pill
**New Transaction →** CTA.
## Statistics chart
`src/components/statistics-chart.tsx` is a **hand-rolled inline SVG** area+line
sparkline — no charting dependency. It takes a numeric series (derived from real
transaction data, bucketed by month; falls back to representative seeded points
when there isn't enough data), draws a violet→indigo gradient stroke over a soft
gradient area fill, emphasizes the latest point, and labels the axis with the
final point highlighted in violet. It includes an `aria-label` and an
`sr-only` numeric summary.
## Credit-card visual
`src/components/card-visual.tsx` exports `GradientCreditCard` (the vivid violet
gradient face: EMV chip, masked `•••• •••• •••• last4`, holder, valid-thru, and a
brand mark) plus `VisaWordmark` and the overlapping-circles `MastercardMark`. A
`subtle` variant renders the dimmed card that peeks behind the active one.
## CopilotKit chat popup
The embedded `CopilotPopup` is themed **via CSS only** — no component props
were changed. The v2 chat scopes its shadcn-style tokens to `[data-copilotkit]`;
`globals.css` re-points `--primary` / `--primary-foreground` / `--ring` on that
selector (and its dark variant) to the brand violet, so the send button, focus
rings, and links match. This is additive and degrades gracefully if the SDK's
internals change. All chat wiring (provider, threads, suggestions, HITL) is
untouched.
## Accessibility
- Focus-visible rings (`ring-brand` with offset) on buttons, tabs, inputs, menu
items, and the help/nav controls.
- Semantic `<button>` / `<nav>` / `aria-current="page"` on the active nav item;
`aria-label`s on icon-only controls; `role="img"` + `aria-label` on the chart.
- Color pairings (ink on surface, brand-foreground on gradient, positive/negative
on their soft backgrounds) target legible contrast in both themes.
@@ -0,0 +1,548 @@
# Learning-Track Plan — make the teach-mode loop actually learn in the banking demo
> **What this is.** A design doc (not an implementation) for closing the loop on the
> banking teach-mode demo: human demonstrates the over-limit unlock → the demonstration is
> recorded on the thread → distilled into `/knowledge` → a fresh agent performs the unlock
> unaided. Plus the three pieces of teachable-demo UX (suggested prompt, inline HITL,
> recording vignette) that make the loop legible on screen.
>
> **Owner:** jerel@copilotkit.ai · **Date:** 2026-06-05 · **Status:** Draft for review
>
> **Repos in play (read both):**
>
> - **Banking demo (canonical, OSS):** `CopilotKit/examples/showcases/banking` — Next.js App
> Router, CopilotKit v2 hooks, `workspace:*` packages (react-core 1.59.2, **no recording
> hook**). This is where the gate/unlock/framing/UX live and verify today.
> - **Intelligence repo (the backend + the real hook):** `cpk-intelligence-banking` —
> Nx monorepo with `apps/app-api`, `apps/realtime-gateway`, `apps/sl-worker`, and
> `demos/{e-commerce,banking,…}`. Its root `package.json` pins
> `@copilotkit/react-core@e103a19` (the build that **exports** the recording hook).
> - **Cookbook contract:** `./README.md` (the 5-role teachable loop) + the recording seam at
> `../../src/lib/record-user-action.ts` (a no-op shim today) + `./verify-teachable-gate.sh`
> (the backend-independent REST proof).
---
## 0. TL;DR
The **left half** of the teachable loop (gate → symptom → agent framing → human unlock → a
`recordUserAction(...)` call site) is built and verifiable in the banking demo today. The
**right half** (recording actually streams → distill → `/knowledge` → fresh agent learns)
is gated on two blockers:
- **Blocker 2a — the recording hook.** `useRecordUserActionInCurrentThread` does not exist
in the OSS `@copilotkit/react-core/v2` build the banking demo currently resolves
(`workspace:*` → 1.59.2). It exists at CopilotKit commit `e103a19`. Clearing it = pin that
build + a one-line import swap of the `record-user-action.ts` shim.
- **Blocker 2b — the Intelligence backend.** The runtime route is already env-gated on
`INTELLIGENCE_API_URL` / `INTELLIGENCE_GATEWAY_WS_URL` / `INTELLIGENCE_API_KEY`. Clearing
it = stand up the Intelligence stack (`app-api` + `realtime-gateway` + `sl-worker`
`/knowledge`) locally or hosted, and point those three env vars at it.
The three teachable-demo UX pieces — **suggested prompt**, **inline HITL tool-call card**,
**pulsating recording vignette** — can be built TODAY against the no-op shim and a local
`recording` flag, because none of them needs the backend to render. They form the
buildable-now track (FOR-148). The live learn/distill/retrieve loop is the blocked track
(FOR-146/147/149). FOR-145 is the fresh-agent verification harness.
Recommended sequencing: **UX shell now (FOR-148)** in parallel with **standing up the
backend (FOR-147)**; then **pin the hook + swap the import (FOR-146)**; then the
**fresh-agent proof (FOR-145/149)** ties it together.
---
## 1. The loop, end to end — concretely, against THIS demo
The banking entities are: `transaction` (the gated write is _approve_), `expense-policy`
(the limit that blocks it), `policy-exception` (the unlock record), and a
`policy-exception-code` catalogue (justifying vs decoy vs invalid).
| Step | What happens | THIS demo's concrete entities + file |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **1. Agent A tries the obvious write** | A fresh agent is asked to approve an over-limit transaction. It calls the approve write. | The agent prompt in `src/app/api/copilotkit/[[...slug]]/route.ts` (`bankingAgent`) lists the tools but withholds the unlock recipe. The approve path is the `showAndApproveTransactions` HITL in `src/app/page.tsx``changeTransactionStatus` (`src/app/actions.ts`) → `PUT /api/v1/transactions/[id]`. |
| **2. Gate fails, symptom-only** | The PUT returns **422 `OVER_POLICY_LIMIT`**, message `"<policy> policy limit exceeded"`. It names the _problem_ (over limit), never the _fix_ (policy exception). | `src/app/api/v1/transactions/[id]/route.ts` (the `patch.status === "approved" && !isWithinPolicyLimit && !hasApprovedException` branch). Rules in `src/lib/store.ts`: `isWithinPolicyLimit` / `hasApprovedException` / `canApprove`. Seed: `t-1` (Google Ads, 5000, Marketing limit 5000/spent 500) ⇒ over limit. |
| **3. Agent stops (framing holds)** | Per the **ACTION DISCIPLINE** clause, the agent does not improvise. It reports the failure and asks the human how to proceed. It does NOT fire a distractor (`sendSpendAlert` / `requestCardReplacement` / `flagForReview`). | Prompt + distractor tools in the runtime route and `src/app/page.tsx` (the three `useFrontendTool` no-op distractors). This is the **control**: pre-learning, a correctly-framed agent cannot pass. |
| **4. Human demonstrates the unlock** | A human opens a policy exception under a **justifying** code (e.g. `EXC-BOARD-APPROVED`), finalizes it (auto-approves + links `activeExceptionId`), then re-approves — now **201**. | Today: `PolicyExceptionModal` (`src/components/policy-exception-modal.tsx`) opened from the over-limit row in `src/components/transactions-list.tsx`. Catalogue: `src/app/api/v1/policy-exception-codes.ts` (`JUSTIFYING_EXCEPTION_CODES` = BOARD-APPROVED / CONTRACTUAL-COMMITMENT / EMERGENCY-SPEND; decoys = WILL-REIMBURSE / ONE-TIME). REST: `exceptions/route.ts` + `exceptions/[id]/finalize/route.ts`. **This plan moves that flow inline into the chat — see §3.2.** |
| **5. Each mutation is recorded on the thread** | After `open()` and after `finalize()`, the UI calls `recordUserAction({title, description, previousData, newData, metadata})`. `previousData` carries the gated flags (`approvePermitted: false`); `newData` the unlocked effect (flipped flags + the linking exception id); `metadata` the `transactionId`. | Two existing calls in `policy-exception-modal.tsx` (`policy_exception.opened`, `policy_exception.finalized`) + two in `transactions-list.tsx` (`transaction.approved`, `transaction.denied`). The import is the no-op shim `@/lib/record-user-action` **today** — see Blocker 2a. |
| **6. Events stream to the gateway** | With the real hook + Intelligence runtime, every AG-UI event of the run (including the recorded user actions) streams over the Phoenix WebSocket to the Intelligence gateway, scoped to the current user + thread. | The env-gated `CopilotKitIntelligence({apiUrl,wsUrl,apiKey})` branch of `createRuntime()` in the runtime route; `identifyUser` maps `properties.userRole` → a stable `northwind-<role>` id so threads + knowledge are scoped consistently. Reference: `cpk-intelligence-banking/demos/e-commerce/bff/.../main.ts`. |
| **7. Distilled into `/knowledge`** | The `sl-worker` sweeps the recorded actions and an LLM writer distills a reusable procedure: _"to approve an over-policy-limit transaction, open a policy exception under a justifying code (board-approved / contractual / emergency), finalize it, then approve."_ It lands in `/knowledge` (shared per org+project). | `apps/sl-worker` in the Intelligence repo (gated on `SL_ENABLED=true`); writes `cpki.knowledge_base_files` exposed to agents as `/knowledge`. |
| **8. Agent B (fresh) learns + succeeds** | In a NEW thread with no memory of the human, the agent is asked the same over-limit approval. It greps `/knowledge` (via the `copilotkit_knowledge_base_shell` tool), discovers the procedure, files a _justifying_ exception, finalizes it, approves → **201** — no human help, nothing added to the prompt. | Same `bankingAgent` prompt (still recipe-free) reading `/knowledge`. This is the **proof of learning** — see §5. |
The contrast in step 5 (`previousData` gated flags vs `newData` unlocked flags) is the signal
the distiller turns into the procedure — which is why the flag names must stay stable across
`open → finalize`. This invariant is already honored in both call sites; do not break it.
---
## 2. The two blockers + how to clear them, in order
### 2a. The recording hook (FOR-146) — pin the build, swap one import
**Current state (verified).**
- Banking demo `package.json` pins CopilotKit packages as `workspace:*`; the installed
`@copilotkit/react-core` is **1.59.2**, whose `v2` hooks index exports
`useFrontendTool` / `useHumanInTheLoop` / `useAgent` / `useThreads` / `useComponent` /
`useConfigureSuggestions` — but **not** `useRecordUserActionInCurrentThread`
(`grep` of `node_modules/@copilotkit/react-core/dist/v2/` returns nothing).
- So all four call sites import the **no-op shim** at `src/lib/record-user-action.ts`. The
shim returns a `recordUserAction` that only `console.debug`s in dev and resolves — it
records nothing. The call-site bodies are already byte-for-byte what the real hook expects.
- The Intelligence repo's root `package.json` pins the hook-bearing build:
`"@copilotkit/react-core": "https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/react-core@e103a19"`
(and the matching `core` / `runtime` / `shared` / `sdk-js` at `e103a19`). The e-commerce
demo there imports `useRecordUserActionInCurrentThread` directly from
`@copilotkit/react-core/v2` and it resolves — proving `e103a19` exports it.
**The unblock, exactly.**
1. **Land/pin a react-core build that exports the hook.** Two options:
- **(A) Pin the published pkg.pr.new build** (matches the Intelligence repo). In the
banking demo `package.json`, replace the four `workspace:*` CopilotKit entries with the
`e103a19` pins (at minimum `@copilotkit/react-core`, plus `@copilotkit/core`,
`@copilotkit/runtime`, `@copilotkit/shared` to keep the runtime route's
`CopilotKitIntelligence` / `BuiltInAgent` imports on the same line). Re-install.
_Cost:_ the demo leaves the OSS monorepo `workspace:*` graph; lockfile churn. Best when
the demo is being **vendored into the Intelligence repo** (where these pins already
exist — see §6).
- **(B) Land the recording hook into the OSS `react-core/v2` build** the demo's
`workspace:*` already resolves, then bump. _Cost:_ a real OSS change; out of scope for
this demo plan but the cleaner long-term home. Treat as a CopilotKit-core ticket.
2. **The one-line import swap** at each of the **four** call sites. Change ONLY the import;
every `recordUserAction({...})` body and the `UserActionRecord` type stay identical:
```ts
// before — no-op shim (banking today):
import { useRecordUserActionInCurrentThread } from "@/lib/record-user-action";
// after — real hook (e-commerce already does this):
import { useRecordUserActionInCurrentThread } from "@copilotkit/react-core/v2";
```
Call sites to edit: `src/components/policy-exception-modal.tsx` (line 10),
`src/components/transactions-list.tsx` (line 15) — plus the two inline-HITL components this
plan introduces in §3.2 (which inherit the same import). **Recommended variant:** instead
of editing imports, turn the shim into a **re-export** so zero call sites change:
```ts
// src/lib/record-user-action.ts (after the hook ships):
export { useRecordUserActionInCurrentThread } from "@copilotkit/react-core/v2";
export type { UserActionRecord } from "@copilotkit/react-core/v2"; // if the type is exported there
```
This is the smallest possible diff and keeps the "copy this file verbatim" cookbook story
intact.
**Definition of done for 2a:** the real hook resolves; `recordUserAction(...)` calls stream
to the runtime instead of `console.debug`; no call-site body changed.
### 2b. The Intelligence backend (FOR-147) — stand up record→distill→`/knowledge`
**Current state (verified).** The runtime route already env-gates the backend:
```ts
const intelligenceEnabled = Boolean(
INTELLIGENCE_API_URL && INTELLIGENCE_GATEWAY_WS_URL && INTELLIGENCE_API_KEY,
);
// enabled → new CopilotRuntime({ agents:{default:bankingAgent}, intelligence, identifyUser })
// missing → new CopilotRuntime({ agents:{default:bankingAgent}, runner: new InMemoryAgentRunner() })
```
So no route code changes to _turn on_ the backend — it is purely a deploy + env exercise.
What each var points at:
| Env var | Points at | Local-dev value | Notes |
| ----------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `INTELLIGENCE_API_URL` | `apps/app-api` HTTP — the `/knowledge` + threads + user_actions store | `http://localhost:7050` (`APP_API_PORT` default in `scripts/local-dev.sh`) | The durable backend the gateway writes to and `/knowledge` is read from. |
| `INTELLIGENCE_GATEWAY_WS_URL` | `apps/realtime-gateway` Phoenix WebSocket — where AG-UI run events (incl. recorded actions) stream | `ws://localhost:7053` (`REALTIME_GATEWAY_PORT` default) | The live ingestion seam. |
| `INTELLIGENCE_API_KEY` | the org/project key (scopes threads + `/knowledge`) | the seeded `cpk_…` key for `casa-de-erlang` (e-commerce uses `cpk_sPRVSEED_seed0privat0longtoken00`) | Must belong to an org whose `cpki.users` includes the demo identities `identifyUser` mints (`northwind-<role>`), or those users must be seeded. |
| `COPILOTKIT_LICENSE_TOKEN` | optional, read automatically by the runtime | — | Only if the build requires it. |
| `SL_ENABLED` | gate on the **`sl-worker`** distillation sweep (Intelligence side) | `true` | Without it the worker won't distill — recording streams but `/knowledge` never fills. |
**Standing it up — two paths.**
- **Local-dev (recommended for building the proof).** The Intelligence repo's
`scripts/local-dev.sh` already boots the whole stack with `pnpm nx serve`:
`app-api` (`:7050`), `realtime-gateway` (`:7053`), the per-demo BFFs, and — when
`SL_ENABLED=true` — the `sl-worker` on a heartbeat. It **already registers a `banking`
demo (demo 6: BFF `:7071`, web `:7072`)** alongside e-commerce. So the realistic path to a
live banking loop is to run the demo **inside the Intelligence repo** (where the `e103a19`
pins and the SL services already exist), not to point the standalone Next.js app at a
hand-rolled stack. See §6 for that recommendation. If you keep the standalone Next.js app,
set the three env vars to the local-dev URLs/key above and run `scripts/local-dev.sh` in the
Intelligence repo to provide the backend.
- **Hosted.** Point the three vars at a deployed Intelligence environment (app-api URL,
gateway WSS URL, a real `cpk_…` key) — e.g. a Railway/staging deploy of the Intelligence
apps. Same route code; only env differs. Ensure `SL_ENABLED=true` on that environment's
`sl-worker` and that the key's org has the demo users seeded.
**Definition of done for 2b:** with 2a also done, a recorded human unlock results in a
distilled procedure appearing in `/knowledge` (verifiable by grepping `/knowledge` via the
agent or inspecting `cpki.knowledge_base_files`).
**Ordering.** 2a and 2b are independent to _build_ but both required for the live loop. Do
2b's standup in parallel with the UX shell; 2a is the final flip. The cleanest single move is
§6 (vendor into the Intelligence repo), which clears 2a and 2b together because the pins and
services already live there.
---
## 3. The teachable-demo UX (first-class — explicit feedback)
These three make the loop legible. **All three are buildable today** against the no-op shim
and a local `recording` flag (§4). Map below is to real files in
`CopilotKit/examples/showcases/banking`.
### 3.1 Suggested prompt — a starter pill that kicks off the teachable scenario
**Goal.** The first thing a viewer sees offers a one-click path straight into the over-limit
gate, so the "agent fails → human teaches → agent learns" arc starts without anyone having to
know the domain.
**Where it plugs in.** `src/app/wrapper.tsx` already registers welcome-screen pills via
`useConfigureSuggestions` (the v2 way — there is no `suggestions` prop on the v2 chat
component). The `BankingSuggestions()` component calls it with
`available: "before-first-message"` and three pills today.
**The change.** Add the teachable pill as the **first** suggestion so it leads. Exact copy:
```ts
useConfigureSuggestions({
available: "before-first-message",
suggestions: [
{
title: "Approve the $5,000 Marketing transaction",
message:
"Approve the $5,000 Google Ads transaction on the Marketing policy.",
},
{ title: "View transactions", message: "Show me my recent transactions" },
{ title: "Add a card", message: "Add a new credit card" },
{
title: "Assign a policy",
message: "Assign a spending policy to one of my cards",
},
],
});
```
- The `message` deliberately matches seed `t-1` (Google Ads, $5,000, Marketing policy, limit
$5,000 / spent $500), so approving it hits `OVER_POLICY_LIMIT` — the gate — every time.
- Keep `title` human and benign ("Approve the $5,000 Marketing transaction"); it must **not**
hint at the exception path (same symptom-only spirit as the gate).
- Pre-learning the agent will fail this correctly (control). Post-learning it will succeed.
The same pill demonstrates both halves of the arc.
**Touch-points:** `src/app/wrapper.tsx` (`BankingSuggestions`). No other file.
### 3.2 Inline HITL — approve/deny + file-exception rendered IN the chat
**Goal.** The human's whole demonstration — see the over-limit symptom, approve/deny, and
_file a policy exception_ — happens **inline in the chat as a tool-call card**, not in a
separate page modal. That's what makes the recorded demonstration feel like "the agent
watched me do it right here."
**What exists today.**
- The approve/deny inline card is **already** an inline HITL: `showAndApproveTransactions`
(`src/app/page.tsx`, ~line 405) renders `<TransactionsList showApprovalInterface>` inside
the chat via `useHumanInTheLoop`'s `render`. That list shows the **over-limit symptom**
("Over policy limit" badge) and a **"File policy exception"** button — but that button
currently opens a **separate page modal** (`PolicyExceptionModal` mounted at the bottom of
`transactions-list.tsx`, a shadcn `Dialog`). That modal is the one piece that breaks the
"inline" story.
- The standalone approve/deny buttons (`src/components/approval-buttons.tsx`) are a shared
primitive reused across every HITL card.
**The design.**
1. **New inline component `PolicyExceptionInline`** (`src/components/policy-exception-inline.tsx`),
modeled on `PolicyExceptionModal` but rendered as a chat card (no `Dialog` chrome — the
same rounded `bg-surface` card the other HITL renders use). It shows:
- the **symptom** ("This transaction is over its Marketing policy limit"),
- the **code picker** (the existing `POLICY_EXCEPTION_CODES` select — labels for humans,
codes persisted),
- **File exception** (calls `openPolicyException` → `finalizePolicyException`, the same
REST callers threaded from `useCreditCards` in `actions.ts`),
- and on success a confirmation + the approve affordance.
It carries the **same two `recordUserAction` calls** (`policy_exception.opened` →
`policy_exception.finalized`) verbatim from the modal — the recording payloads do not
change.
2. **A new inline HITL tool `fileAndApproveOverLimit`** (a `useHumanInTheLoop` in
`src/app/page.tsx`) whose `render` mounts `PolicyExceptionInline`. Description stays
**neutral** (does not name the exception path or which codes justify — preserves the
learning invariant), e.g. _"Resolve a blocked over-limit approval. Requires human
approval."_ This becomes the inline surface the human uses to teach, and later the surface
the learned agent's `openPolicyException` / `finalizePolicyException` calls render into.
3. **Reuse vs replace.**
- **Reuse** `approval-buttons.tsx` unchanged for approve/deny.
- **Reuse** the existing `openPolicyException` / `finalizePolicyException` HITL tools
(`src/app/page.tsx`, ~lines 492 / 549) — they already render inline approve cards; the
learned agent drives the unlock through these. `PolicyExceptionInline` is the
_human-initiated_ twin.
- **Replace** the page-modal entrypoint: drop the `setExceptionTxnId` → `<PolicyExceptionModal>`
branch in `transactions-list.tsx` in favor of rendering `PolicyExceptionInline` within the
chat card. Keep `policy-exception-modal.tsx` only if a non-chat entry is still wanted;
otherwise retire it (its recording payloads move into the inline component).
**Touch-points:** new `src/components/policy-exception-inline.tsx`; new HITL tool +
`render` in `src/app/page.tsx`; edits to `src/components/transactions-list.tsx` (swap the
modal mount for the inline card); reuse `src/components/approval-buttons.tsx`. Reference shape:
`cpk-intelligence-banking/demos/e-commerce/.../incident-create-modal.tsx` (same
open→record→finalize→record pattern).
### 3.3 Pulsating "recording" vignette — a violet edge glow while recording
**Goal.** While the agent is recording the human's demonstrated actions, a soft pulsating
violet glow/vignette hugs the **canvas edges**, signaling "the agent is watching and recording
this for future reference." It turns off when recording ends.
**Trigger / state.** Introduce a tiny **recording context** (`src/components/recording-context.tsx`)
exposing `isRecording` + `beginRecording()` / `endRecording()` (or a ref-counted
`withRecording()` wrapper). Wire it around the **`recordUserAction` calls**: each call site
calls `beginRecording()` immediately before firing the record(s) and `endRecording()` when the
demonstration step settles. Concretely:
- In `PolicyExceptionInline` (§3.2): `beginRecording()` at the start of `handleSubmit`, and
`endRecording()` after the second (`finalized`) record resolves (or in `finally`).
- In `transactions-list.tsx` approve/deny: wrap the `recordUserAction(...)` call the same way.
- Use a small **debounce/min-duration** (e.g. keep it on ≥1200ms) so a fast fire-and-forget
record still produces a visible pulse, and **ref-count** so overlapping records (open +
finalize) don't flicker it off between steps.
> This flag is **independent of the backend**: it reflects "the UI is emitting a record right
> now," which is true even against the no-op shim. So the vignette is fully demoable today
> (FOR-148) and stays correct once the real hook streams (FOR-146).
**Visual treatment.**
- An **edge vignette**: a full-viewport overlay with `box-shadow: inset 0 0 0 …` /
`radial-gradient` mask so color concentrates at the **edges** and fades to transparent in the
center (content stays unobscured).
- **On-brand violet:** use the existing tokens — `--brand-violet` (`hsl(252 83% 67%)`) /
`--brand-indigo` (`hsl(248 84% 60%)`) and the `--shadow-glow` feel
(`0 12px 30px hsl(252 83% 60% / 0.35)`). Keep alpha low (~0.250.4) so it reads as a glow,
not a wash.
- **Pulse animation:** a 23s `@keyframes` easing opacity/blur between two low values
(e.g. 0.25 ↔ 0.45), `ease-in-out`, infinite while recording. Fade in/out over ~200ms on
enter/leave so it doesn't snap.
- **Non-blocking:** `position: fixed; inset: 0; pointer-events: none; z-index` above content
but below modals/toasts. It must never intercept clicks.
- **Reduced motion:** under `@media (prefers-reduced-motion: reduce)`, drop the pulse — show a
**static** low-opacity violet edge glow instead (still communicates "recording", no
animation).
**Where it mounts.** A top-level overlay so it frames the whole canvas. Mount
`<RecordingVignette />` inside `CopilotKitWrapper` in `src/app/wrapper.tsx` (a sibling of
`LayoutComponent` / `ChatPanel`, both inside the provider tree so it can read the recording
context). The `RecordingProvider` wraps the same subtree. Add the keyframes + `.recording-vignette`
styles to `src/app/globals.css` (which already owns the brand tokens and `.brand-gradient`).
**How it turns off.** When `endRecording()` drops the ref-count to zero (after the min-duration
elapses), `isRecording` flips false; the overlay fades out over ~200ms and the animation stops.
**Touch-points:** new `src/components/recording-context.tsx`; new `src/components/recording-vignette.tsx`;
keyframes/classes in `src/app/globals.css`; mount + provider in `src/app/wrapper.tsx`; `begin/endRecording`
wrapping in `policy-exception-inline.tsx` and `transactions-list.tsx`.
---
## 4. Buildable NOW vs blocked — the explicit split
| Piece | Ticket | Needs the real hook? | Needs the Intelligence backend? | Buildable today? |
| ------------------------------------------------- | ------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------- |
| Suggested prompt (§3.1) | FOR-148 | No | No | **Yes** — pure `useConfigureSuggestions` copy. |
| Inline HITL card (§3.2) | FOR-148 | No (renders the demonstration UI; recording payloads already present via the shim) | No | **Yes** — renders + drives REST unlock; records via the shim. |
| Recording vignette (§3.3) | FOR-148 | No (reads a local `recording` flag set around the record calls) | No | **Yes** — flag is true even against the no-op shim. |
| Recording actually streams (role #3 beyond no-op) | FOR-146 | **Yes** (pin `e103a19` + import swap) | Indirectly (events have somewhere to go) | No — blocked on 2a. |
| Distill → `/knowledge` (role #5) | FOR-147 | — | **Yes** (`app-api` + gateway + `sl-worker`, `SL_ENABLED=true`) | No — blocked on 2b. |
| Fresh-agent learns + succeeds | FOR-149 | Yes | Yes | No — needs 2a + 2b. |
| Fresh-agent verification harness | FOR-145 | The script half works today (§5); the learning half needs 2a+2b | Partial | The REST proof: **yes**. The learning proof: blocked. |
**Why the UX is safe to build first.** The three UX pieces only depend on (a) the v2 chat +
HITL APIs the demo already uses, and (b) a local `recording` boolean. The no-op shim already
keeps the `recordUserAction` call sites real and stable, so wrapping them in `begin/endRecording`
and rendering inline cards is wiring that does not change when the real hook lands — at that
point the same calls simply also stream. **No rework.**
### Recommended sequencing
1. **FOR-148 — UX shell (now, parallelizable).** Suggested prompt → inline HITL card →
recording vignette. Verifiable visually + via `verify-teachable-gate.sh` (the REST contract
is unaffected). Ship this regardless of backend timing — it makes the demo legible today.
2. **FOR-147 — backend standup (now, parallel to FOR-148).** Bring up `app-api` + gateway +
`sl-worker` (local-dev or hosted) and confirm the three env vars + `SL_ENABLED`. No demo
code changes (the route is already gated).
3. **FOR-146 — pin the hook + swap the import (after 147 so streaming has a destination).**
Pin `e103a19` (or re-export shim) → recorded actions now stream.
4. **FOR-149 / FOR-145 — fresh-agent proof.** With 146+147 live, run the learning proof (§5)
and codify it in a harness.
**Strong recommendation (§6):** do FOR-146 + FOR-147 by **vendoring the demo into the
Intelligence repo**, where both the `e103a19` pins and the SL services already exist — that
collapses 2a+2b into "run the existing local-dev with the banking demo wired like e-commerce."
---
## 5. Verification strategy — proving learning, not scripting
### 5.1 Backend-independent REST proof (works TODAY) — roles #1 + #2 — FOR-145 (lower half)
`verify-teachable-gate.sh` already drives the real REST routes against a running banking dev
server and asserts the full gate→unlock contract. Run it (point `BASE_URL` at the served port):
```bash
BASE_URL=http://localhost:3000 ./verify-teachable-gate.sh # next dev defaults to :3000
```
It asserts, in order:
- **A. GATE** — `PUT /api/v1/transactions/t-1 {"status":"approved"}` → **422
`OVER_POLICY_LIMIT`**, and the body does **not** mention the exception/unlock path
(symptom-only invariant).
- **B. UNLOCK** — open `EXC-BOARD-APPROVED` on `t-1` → **201** → finalize → **200 approved** →
re-approve `t-1` → **201** (gate lifted by a justifying code).
- **C. DECOY** — `EXC-WILL-REIMBURSE` on `t-3` files + finalizes (**201/200**) but the approve
stays **422** (decoy does not justify).
- **D. CATALOGUE** — an invalid code → **422 `INVALID_EXCEPTION_CODE`**, body does **not**
enumerate the catalogue (non-enumeration invariant).
This proves the gate is real and the unlock is discriminating — i.e. there is genuinely
_something to learn_ — without any Intelligence backend. It is the control that the demo isn't
faked. Re-run from a fresh server to reseed (in-memory store).
### 5.2 The fresh-agent proof (activates after 2a + 2b) — roles #3 + #5 — FOR-149
This is the proof the loop **learned**, not that REST works. Requires the real hook (2a) and
the env-gated `CopilotKitIntelligence` backend with `SL_ENABLED=true` (2b).
1. **Baseline (control).** Fresh thread, ask: _"Approve the $5,000 Google Ads transaction on
the Marketing policy."_ With the recipe-free prompt + ACTION DISCIPLINE intact, the agent
hits the gate, has no procedure, and **reports the failure** instead of firing a distractor.
_This failure is the control — record it._
2. **Human teaches.** Open the inline policy-exception card (§3.2), pick a **justifying** code,
file + finalize. Each step fires `recordUserAction(...)` on the current thread — now a real
stream (2a), and the vignette (§3.3) confirms recording is live on screen.
3. **Distill.** The `sl-worker` (2b, `SL_ENABLED=true`) distills the recorded actions into a
reusable procedure in `/knowledge`. _Spot-check:_ grep `/knowledge` (via the agent or
`cpki.knowledge_base_files`) and confirm the over-limit/policy-exception procedure exists.
4. **Fresh agent succeeds unaided.** A **new** thread (and ideally a different seeded user, to
prove cross-thread/cross-user transfer), same approval request. The agent greps `/knowledge`,
files a _justifying_ exception, finalizes, approves → **201** — **no human help, nothing
added to the prompt.**
**Pass criteria:** step 1 fails, step 4 succeeds, and the **only** thing that changed between
them is the distilled `/knowledge`. That delta is the learning. Codify as the FOR-145 harness:
the REST proof (§5.1) gates "is there something to learn," the fresh-agent run gates "did it
learn it."
**Anti-cheat checks (keep the proof honest):**
- The agent prompt at step 4 is **byte-identical** to step 1 (recipe still withheld — diff the
runtime route prompt).
- A run that files a **decoy** code must still fail (the agent must learn _which_ codes justify,
not just "file an exception").
- Distractor tools must remain harmless no-ops (a "success" from `sendSpendAlert` must not be
mistaken for clearing the gate).
---
## 6. Recommended path: vendor into the Intelligence repo (clears 2a + 2b together)
The standalone Next.js banking demo can _render_ the full UX today, but the **live learning
loop's natural home is the Intelligence repo**, because:
- it already pins `@copilotkit/react-core@e103a19` (the hook-bearing build) — **2a is free
there**;
- it already runs `app-api` + `realtime-gateway` + `sl-worker` via `scripts/local-dev.sh`, and
already registers a **`banking` demo (demo 6)** beside e-commerce — **2b is free there**;
- the e-commerce demo there is a working reference for the exact wiring: `BuiltInAgent` +
`CopilotKitIntelligence` in the BFF, `identifyUser` from a user header, domain tools in the
browser via `useFrontendTool`, and the **real** `useRecordUserActionInCurrentThread` import.
This aligns with the existing vendor plan
(`docs/superpowers/plans/2026-06-02-vendor-canonical-saas-demo-sl-threads.md`): port the
banking React surface (pages, the inline HITL card, the vignette, suggestions) into
`demos/banking/react`, keep the prompt + Intelligence wiring in `demos/banking/bff`, and the
two blockers dissolve into "run the local-dev that's already there." The standalone Next.js
app remains the OSS-verifiable artifact for roles #1/#2/#4 + the UX shell; the Intelligence
copy is where roles #3/#5 actually run.
If vendoring is deferred, the standalone demo can still close the loop by (A) pinning `e103a19`
in its own `package.json` and (B) pointing its three env vars at an Intelligence backend
(local-dev in the Intelligence repo, or hosted) — but that hand-wires what the vendor path
gives for free.
---
## 7. File / component touch-point summary
**Edit (UX shell, FOR-148 — buildable now):**
- `src/app/wrapper.tsx` — add the teachable suggestion pill (§3.1); mount `RecordingProvider`
- `<RecordingVignette />` (§3.3).
- `src/app/page.tsx` — add the `fileAndApproveOverLimit` inline HITL tool whose `render` mounts
`PolicyExceptionInline` (§3.2).
- `src/components/transactions-list.tsx` — swap the page-modal entry for the inline card; wrap
the approve/deny `recordUserAction` calls in `begin/endRecording` (§3.2, §3.3).
- `src/app/globals.css` — `.recording-vignette` + `@keyframes` + reduced-motion variant (§3.3).
**Create (UX shell, FOR-148):**
- `src/components/policy-exception-inline.tsx` — inline version of the file-exception flow
(carries the two existing recording payloads verbatim).
- `src/components/recording-context.tsx` — `isRecording` + `begin/endRecording` (ref-counted,
min-duration).
- `src/components/recording-vignette.tsx` — the edge-glow overlay.
**Edit (unblock the loop):**
- `package.json` — pin `@copilotkit/react-core` (+ core/runtime/shared) to `e103a19` (FOR-146,
option A) — or do this via vendoring (§6).
- `src/lib/record-user-action.ts` — turn into a re-export of the real hook (FOR-146) so no call
site changes.
- **Env only (no code):** set `INTELLIGENCE_API_URL` / `INTELLIGENCE_GATEWAY_WS_URL` /
`INTELLIGENCE_API_KEY` (+ backend `SL_ENABLED=true`) (FOR-147). The runtime route
(`src/app/api/copilotkit/[[...slug]]/route.ts`) already branches on these.
**Reuse unchanged:** `src/components/approval-buttons.tsx`; the existing `openPolicyException`
/ `finalizePolicyException` HITL tools in `page.tsx`; the gate route; the catalogue; `store.ts`;
`verify-teachable-gate.sh`.
**Reference (read-only):** `cpk-intelligence-banking/demos/e-commerce/{bff/.../main.ts,
react/.../order-actions-bar.tsx, react/.../incident-create-modal.tsx}` and
`scripts/local-dev.sh`.
---
## 8. Risks / open questions
- **Hook parity at `e103a19`.** Confirm the published `react-core@e103a19` `v2` index exports
`useRecordUserActionInCurrentThread` _and_ the `UserActionRecord` type (e-commerce imports the
hook; verify the type export before relying on it in the re-export shim — otherwise keep the
local type).
- **Pin drift vs `workspace:*`.** Pinning the demo to `e103a19` takes it off the OSS monorepo
graph; the runtime route imports `CopilotKitIntelligence` / `BuiltInAgent` from
`@copilotkit/runtime/v2`, so pin runtime to the same commit to avoid a split-brain build.
Vendoring (§6) sidesteps this.
- **Identity scoping.** `identifyUser` mints `northwind-<role>`; the `INTELLIGENCE_API_KEY`'s
org must have those users seeded (e-commerce seeds four users in `casa-de-erlang`). Mismatch
= threads/knowledge land under an unexpected scope and the fresh-agent retrieval misses.
- **Writer non-determinism.** The LLM distiller may phrase `/knowledge` differently run to run;
keep a deterministic fallback note for scripted live demos, and assert on _behavior_ (the 201) not on the knowledge text.
- **Vignette over modals.** Ensure the overlay's `z-index` sits above page content but **below**
HITL cards/toasts, and `pointer-events: none` everywhere, so it never blocks the approve
buttons the human needs during recording.
- **Inline modal retirement.** Decide whether to fully retire `policy-exception-modal.tsx` or
keep it as a non-chat entry; if retired, ensure its recording payloads are preserved exactly
in `policy-exception-inline.tsx` (the distiller depends on the stable flag names).
---
## 9. Ticket map (suggested)
| Ticket | Scope |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **FOR-145** | Verification harness: REST gate proof (works today) + fresh-agent learning proof (activates post-146/147). |
| **FOR-146** | Recording hook unblock: pin `e103a19` (or re-export shim) + one-line import swap. |
| **FOR-147** | Intelligence backend standup: `app-api` + gateway + `sl-worker` (`SL_ENABLED=true`); wire the three env vars (local-dev or hosted). |
| **FOR-148** | Teachable-demo UX shell (buildable now): suggested prompt + inline HITL card + recording vignette. |
| **FOR-149** | Close the loop: with 146+147 live, demonstrate fresh-agent success unaided and capture it in the harness. |
@@ -0,0 +1,405 @@
# teach-mode cookbook
A reusable recipe for building **self-learning, teachable** CopilotKit demos.
"Teach mode" is the loop where an agent **fails a task it was never told how to
do**, a human **demonstrates** the workaround in the UI, that demonstration is
**recorded → distilled → written to `/knowledge`**, and a **fresh agent then
succeeds unaided**. The agent didn't have the recipe prompt-stuffed in; it
_learned_ it from watching a person.
This loop is already implemented **identically** in two demos — only the domain
entities differ. This cookbook documents the contract they share so a **third
demo is a copy-and-reskin**, not a redesign.
| Demo | Path | Entities |
| -------------------------- | ------------------------------------------- | ----------------------------------------------------- |
| **Banking** (canonical) | `examples/showcases/banking` | `transaction` / `expense-policy` / `policy-exception` |
| **E-commerce** (reference) | `cpk-intelligence-banking/demos/e-commerce` | `order` / `refund` / `incident-report` |
> Paths in this doc are repo-relative to `CopilotKit/` for banking, and to
> `cpk-intelligence-banking/` for e-commerce.
---
## (a) What teach-mode is — the teachable loop
The whole demo turns on one asymmetry: **the agent is given the goal and the
tools, but NOT the procedure.** A gate blocks the obvious write with a
symptom-only error. A human knows the unlock and performs it in the UI. That
human action is captured and distilled into knowledge. A later agent reads the
knowledge and clears the same gate on its own.
```
┌─────────────────────────────────────────────┐
│ Agent A (knows the goal + tools, NOT the │
│ procedure) tries the obvious write │
└───────────────────────┬─────────────────────┘
role #1 GATE ──► write FAILS with a SYMPTOM-ONLY error ─────────────┐
("<policy> policy limit exceeded" — 422) │
names the PROBLEM, never the FIX │
role #4 AGENT FRAMING: prompt withholds the recipe + ships DISTRACTOR │
tools + ACTION DISCIPLINE ──► agent CANNOT bluff its way past; │
it stops and reports. │
role #2 UNLOCK: a HUMAN performs the multi-step workaround in the UI │
file a record under a JUSTIFYING code → finalize → link to entity │
(DECOY codes file but don't justify; INVALID codes are rejected) │
┌─────────────────────────────────────────┘
role #3 RECORDING: each human UI mutation is captured on the CURRENT
thread via useRecordUserActionInCurrentThread()
recordUserAction({ title, description, previousData, newData, metadata })
previousData = the gated flags · newData = the unlocked effect
role #5 KNOWLEDGE BACKEND: writer agent DISTILLS the recorded actions
────► /knowledge (a reusable "to clear this gate, do X" procedure)
┌─────────────────────────────────────────────┐
│ Agent B (FRESH, no memory of A) retrieves │
│ /knowledge and clears the SAME gate UNAIDED │ ◄── proof of LEARNING
└─────────────────────────────────────────────┘
```
The left half (gate → symptom → framing → human unlock → recording call) works
and is **verifiable today** with no Intelligence backend. The right half
(distill → `/knowledge` → fresh agent) activates when the self-learning
react-core + Intelligence runtime are wired — see role #5 and the honest
backend-block note in **(e)**.
> **Banking's narrated dashboard variant (PR #5266).** On top of this contract,
> the banking demo drives the unlock as an _agent-orchestrated, narrated_ loop.
> When asked to approve an over-limit charge it has no saved procedure for, the
> agent declines ("I don't have a saved way to approve an over-limit charge yet")
> and offers to record (`offerWorkflowRecording`) — no approval card is shown.
> The officer demonstrates the unlock on the real **/dashboard → Transactions →
> Pending approval** view (file a justifying exception, then approve) while a
> waiting card (`awaitDashboardDemonstration`) holds the chat; the agent then
> summarizes and saves the procedure (`saveLearnedWorkflow`) and, on a later
> request, applies it itself to a _different_ over-limit charge
> (`openPolicyException` → `finalizePolicyException` → `approveTransaction`).
> Because the demonstration happens on a different route, these teach/recall HITL
> tools are registered **globally** in `src/components/copilot-context.tsx` (not
> in a page component) so they survive navigation — a route-scoped registration
> unmounts mid-run and the followUp never fires. Same-session recall works by
> echoing the saved procedure back into the thread; the cross-thread `/knowledge`
> proof still requires the backend (role #5).
>
> The waiting card ("Recording your workflow") stays **non-directional** — it
> never lists the steps ("go ahead and do it yourself now and I'll watch and
> learn"), since the point is the agent doesn't yet know how. The card embeds a
> live **recorder feed** (`RecordingSteps` in `src/components/recording-feed.tsx`,
> fed by `logStep` from the nav / tab / file-exception / approve call sites) that
> narrates each officer action as it happens ("Opened Dashboard" → "Filed the
> policy exception" → "Approved the charge"). It renders INSIDE the chat card (a
> child component subscribed to the recording context, so it updates live without
> a stale-closure dep), reading consistently with the other cards rather than as
> a floating overlay. `saveLearnedWorkflow`'s tool result is
> **directive** so the model renders the Save card instead of asking "should I
> save this?" in prose (the failure that otherwise leaves the user nothing to
> click). After saving, the agent treats the demonstrated charge as already
> cleared and waits, rather than re-running the fresh procedure on it.
---
## (b) The 5-role contract (with load-bearing invariants)
State each role demo-agnostically. The **invariant** is the part you must not
break when reskinning — it's what makes the demo _prove learning_ rather than
merely _script a workflow_.
### 1. GATE — a write that fails with a SYMPTOM-ONLY error
A normal-looking write (approve, refund, …) is blocked when a domain rule isn't
satisfied. The rejection **names the problem, never the fix**.
> **Invariant.** The error is symptom-only. It may say _"\<policy\> policy limit
> exceeded"_; it must NEVER mention the policy-exception path (or whatever the
> unlock is). Leaking the recipe in the error lets the agent derive it in one
> round-trip and defeats the demo. The gate must also be _liftable_ — it passes
> once the unlock is in place (`isWithinLimit(x) || hasApprovedException(x)`).
### 2. UNLOCK — a discriminating multi-step procedure that lifts the gate
A human (and, post-learning, the agent) lifts the gate by **filing a record
under a JUSTIFYING code → finalizing it → linking it** to the entity. The
catalogue mixes justifying codes with **decoys**, and unknown codes are
**rejected without enumeration**.
> **Invariant.** The procedure is _discriminating_: only JUSTIFYING codes lift
> the gate; DECOY codes file successfully (recorded for history) but do NOT
> justify; INVALID codes are rejected _without listing the valid ones_. The
> agent is **never told which codes justify** — it must learn that from observed
> human flows. (If any code worked, or the catalogue were leaked, there'd be
> nothing to learn.)
### 3. RECORDING surface — human UI mutations captured on the current thread
Every human mutation that advances the unlock is recorded on the current thread
via `useRecordUserActionInCurrentThread()`, called as
`recordUserAction({ title, description, previousData, newData, metadata }).catch(...)`.
> **Invariant.** The record shape is fixed and identical across demos:
> `previousData` carries the **gated capability flags** (e.g.
> `approvePermitted: false`), `newData` the **unlocked effect** (flipped flags +
> linking ids), `metadata` the **domain ids**. `title` is a machine-ish dotted
> event name (e.g. `policy_exception.opened`); `description` is one human
> sentence. The contrast between `previousData` and `newData` is the signal the
> distiller learns from — keep flag names stable across the open→finalize steps.
### 4. AGENT FRAMING — withhold the recipe, ship distractors, enforce discipline
The system prompt lists the unlock's tools but **never the procedure**, and
ships **plausible distractor tools** that look helpful but don't lift the gate.
An **ACTION DISCIPLINE** clause forbids improvising a substitute.
> **Invariant.** A successful unlock must prove **learning, not
> prompt-stuffing**. So: (a) the prompt withholds the unlock recipe; (b) it
> ships distractors (banking: `sendSpendAlert`, `requestCardReplacement`,
> `flagForReview`) so "called a plausible tool" ≠ "cleared the gate"; (c) ACTION
> DISCIPLINE makes the agent stop and report on failure rather than guess. Before
> learning, the correctly-framed agent _cannot_ pass.
### 5. KNOWLEDGE BACKEND — record → distill → `/knowledge` → fresh agent learns
Recorded actions are distilled into `/knowledge`; a fresh agent retrieves it and
succeeds unaided. The runtime is **env-gated**: OSS `InMemoryAgentRunner` by
default, `CopilotKitIntelligence` when configured.
> **Invariant.** The backend is a **swappable seam**, and roles #1#2 are proven
> _without_ it. **Honest current block:** the OSS `@copilotkit/react-core/v2`
> build does **not** yet export the recording hook, so the recording surface
> (role #3) is a **no-op shim** today — gate/unlock/framing all work and verify,
> but the distill→`/knowledge`→fresh-agent leg is deferred until the
> self-learning react-core ships. The hook exists at CopilotKit commit
> `e103a19` (pinned by the Intelligence repo); adoption is then a **one-line
> import swap** (call sites don't change). See **(e)**.
---
## (c) Worked-example mapping table
Each role → the banking file → the e-commerce file → what you swap for a new demo.
| Role | Banking (`examples/showcases/banking`) | E-commerce (`cpk-intelligence-banking/demos/e-commerce`) | What you swap for a new demo |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **#1 GATE** | `src/app/api/v1/transactions/[id]/route.ts` — PUT returns **422 `OVER_POLICY_LIMIT`** when `status==="approved" && !isWithinPolicyLimit && !hasApprovedException`. Rule fns in `src/lib/store.ts`: `isWithinPolicyLimit` / `hasApprovedException` / `canApprove`. | `react/src/app/data/store.ts``processRefund` / `initiateReturn` throw **`REFUND_NOT_PERMITTED` / `RETURN_NOT_PERMITTED`** when `!isWithinRefundWindow && !hasApprovedActiveIncident`. | The gated write + its symptom-only error code. Pick your domain's "blocked action" (publish, ship, escalate…) and the rule that blocks it. |
| **#2 UNLOCK** | Catalogue `src/app/api/v1/policy-exception-codes.ts` (`POLICY_EXCEPTION_CODES`, `JUSTIFYING_EXCEPTION_CODES`, `isValidExceptionCode`, `isJustifying`). REST `src/app/api/v1/exceptions/route.ts` (open, POST) + `src/app/api/v1/exceptions/[id]/finalize/route.ts` (finalize, POST). Store `openPolicyException` / `finalizePolicyException`. | Catalogue `react/src/app/data/incident-codes.ts` (`INCIDENT_CODES`, `REFUND_JUSTIFYING_CODES`, `isValidIncidentCode`). Store `openIncidentReport` / `finalizeIncidentReport`. | The record entity + its code catalogue. Keep 3 justifying + N decoys; keep open→finalize→link; keep the catalogue check that rejects unknown codes **without enumerating**. |
| **#3 RECORDING** | `src/lib/record-user-action.ts` (**no-op shim**) → consumed in `src/components/policy-exception-modal.tsx` (two `recordUserAction` calls: `policy_exception.opened` then `.finalized`). | `@copilotkit/react-core/v2` (**real hook import**) → consumed in `react/src/app/components/incident-create-modal.tsx` (`incident_report.opened` / `.finalized`) and `order-actions-bar.tsx` (`order.refunded` / `order.return_initiated`). | Nothing in the seam itself — copy `record-user-action.ts` verbatim. Swap only the **payload values** (`title`/flags/`metadata`) for your domain. |
| **#4 AGENT FRAMING** | `src/app/api/copilotkit/[[...slug]]/route.ts``BuiltInAgent` prompt withholds the unlock recipe; ships distractors `sendSpendAlert` / `requestCardReplacement` / `flagForReview`; has the **ACTION DISCIPLINE** clause. | Same role in the e-commerce runtime route (refund/return tools listed; distractors present; recipe withheld). | The prompt's tool list, your 3 distractor tools, and the ACTION DISCIPLINE clause (reuse the wording — it's domain-neutral). |
| **#5 KNOWLEDGE BACKEND** | Same route — env-gated `CopilotKitIntelligence` (OSS `InMemoryAgentRunner` default) keyed on `INTELLIGENCE_API_URL` / `INTELLIGENCE_GATEWAY_WS_URL` / `INTELLIGENCE_API_KEY`; `identifyUser` scopes threads by role. | Equivalent env-gated Intelligence runtime in the e-commerce app. | Nothing structural — reuse the env-gated `createRuntime()` pattern verbatim; only `agents: { default: <yourAgent> }` changes. |
---
## (d) Adoption checklist — add teach-mode to a new demo
Eight concrete steps. Assumes a CopilotKit demo with an in-memory store and a v2
runtime route already scaffolded.
1. **Pick the gated write + symptom.** Choose the domain action to block
(approve / refund / publish / ship …) and the rule that blocks it. Add the
rule fns to your store (mirror `isWithinPolicyLimit` / `hasApprovedException`
/ `canApprove`). Make the write's route return a **422 with a symptom-only
error code** (mirror `OVER_POLICY_LIMIT` in
`transactions/[id]/route.ts`). **Do not name the unlock in the error.**
2. **Author the code catalogue (role #2).** Create a `*-codes.ts` (mirror
`policy-exception-codes.ts`): a `CODES` array (`{ code, label }`, label for
humans only), a `JUSTIFYING_CODES` set (keep ~3), `isValid*Code`, and
`isJustifying`. Include **decoy** codes that are valid-but-not-justifying.
3. **Add the unlock record + REST.** Add the record entity to your store with
`open*` (validates code via `isValid*Code`, throws on unknown) and
`finalize*` (auto-approves and links `active*Id` to the gated entity, which is
what `hasApprovedException` checks). Expose them over REST (mirror
`exceptions/route.ts` + `exceptions/[id]/finalize/route.ts`) — or as store
calls if your demo is client-side like e-commerce.
4. **Copy the recording seam (role #3).** Copy
[`record-user-action.ts`](../../src/lib/record-user-action.ts) into your `src/lib/`
**verbatim**. It is domain-neutral; do not edit it.
5. **Wire the human UI to record (role #3).** In the modal/bar where the human
performs the unlock, call `useRecordUserActionInCurrentThread()` and emit a
record after each successful mutation. Follow the field convention exactly:
`previousData` = gated flags (`{ approvePermitted: false }`), `newData` =
unlocked effect (flipped flags + linking ids), `metadata` = domain ids,
`title` = dotted event name, `description` = one sentence. Always
`.catch(...)` (fire-and-forget). Mirror `policy-exception-modal.tsx`.
6. **Frame the agent (role #4).** In your runtime route's prompt: list the
unlock's tools but **not the procedure**; add **3 distractor tools** that look
plausible but don't lift the gate; paste the **ACTION DISCIPLINE** clause
(reuse banking's wording). Implement the distractors as harmless no-ops/logs.
7. **Wire the env-gated backend (role #5).** Reuse banking's `createRuntime()`:
build `CopilotKitIntelligence` when `INTELLIGENCE_API_URL` /
`INTELLIGENCE_GATEWAY_WS_URL` / `INTELLIGENCE_API_KEY` are all set, else fall
back to `InMemoryAgentRunner`. Keep `identifyUser` to scope threads by role.
8. **Verify (role #1+#2 today; #5 when the backend lands).** Adapt
[`verify-teachable-gate.sh`](./verify-teachable-gate.sh) to your entity ids
and codes and run it against your dev server. It must show: gate blocks (422)
→ justifying unlock succeeds → decoy stays blocked → invalid code rejected
without leaking the catalogue. Add the fresh-agent learning proof (**(f)**)
once the Intelligence backend is configured.
---
## (e) The RECORDING SEAM contract
The canonical primitive lives in
[`record-user-action.ts`](../../src/lib/record-user-action.ts) — copy it once, never edit
it.
### The `UserActionRecord` shape
```ts
export type UserActionRecord = {
title: string; // machine-ish dotted event name
description: string; // one human sentence
previousData?: unknown; // GATED state (flags that were false)
newData?: unknown; // UNLOCKED effect (flipped flags + ids)
metadata?: Record<string, unknown>; // domain ids ("which")
};
export const useRecordUserActionInCurrentThread =
() =>
(record: UserActionRecord): Promise<void> => {
/* … */
};
```
Call-site convention, verbatim from `policy-exception-modal.tsx` (the e-commerce
modal is identical bar the entity names):
```ts
const recordUserAction = useRecordUserActionInCurrentThread();
// ...after open() succeeds...
recordUserAction({
title: "policy_exception.opened",
description: "Opened a policy exception from the transactions view.",
previousData: { transactionActiveExceptionId: null, approvePermitted: false },
newData: { exceptionId, exceptionStatus: "draft", exceptionCode: code },
metadata: { transactionId: props.transactionId },
}).catch(console.error);
// ...then after finalize() succeeds, a second record flips the flags to the unlocked state.
```
### The no-op shim (current state)
Today the shim **records nothing** — it only `console.debug`s in dev — because
the OSS `@copilotkit/react-core/v2` build does **not** export
`useRecordUserActionInCurrentThread`. Its hooks index exports only
`useFrontendTool` / `useHumanInTheLoop` / `useAgent` / `useThreads` / etc. The
shim exists purely to keep the call sites real and stable.
### The one-line swap to the real hook
When a react-core build exporting the hook is one you can depend on, change
**only the import** at each call site:
```ts
// before — no-op shim (banking today):
import { useRecordUserActionInCurrentThread } from "@/lib/record-user-action";
// after — real hook (e-commerce already does this):
import { useRecordUserActionInCurrentThread } from "@copilotkit/react-core/v2";
```
The `UserActionRecord` type and every call body stay byte-for-byte identical.
(Alternatively, make `record-user-action.ts` re-export the real hook so not even
imports change.)
### Honest backend-block note
- **Works today, no backend:** roles #1 (gate), #2 (unlock + decoy + catalogue),
#4 (framing). Provable via `verify-teachable-gate.sh`.
- **Deferred until the self-learning react-core + Intelligence runtime are
wired:** the recording actually streaming (role #3 beyond the no-op) and the
distill → `/knowledge` → fresh-agent-learns leg (role #5).
- **Known landing point:** the recording hook exists at CopilotKit commit
`e103a19`, which the Intelligence repo pins. The banking demo points its
import at the shim; the e-commerce demo already imports the hook from
`@copilotkit/react-core/v2` — that single import line is the entire difference
between "backend pending" and "backend wired".
---
## (f) Verification recipe
### Backend-independent proof (works TODAY) — roles #1 + #2
Run the bundled script against a running banking dev server. It drives the real
REST routes and asserts the full gate→unlock contract.
```bash
# default base URL is http://localhost:3939 (next dev defaults to :3000 —
# point BASE_URL at whatever port you actually serve)
./verify-teachable-gate.sh
BASE_URL=http://localhost:3000 ./verify-teachable-gate.sh
```
What it asserts (each step commented in the script with the role it exercises):
- **A. GATE (#1)** — `PUT /api/v1/transactions/t-1 {"status":"approved"}`
**422 `OVER_POLICY_LIMIT`**, and the body does **not** mention the
exception/unlock path (symptom-only invariant).
- **B. UNLOCK (#2)** — `POST /api/v1/exceptions {transactionId:"t-1",
code:"EXC-BOARD-APPROVED"}` → **201** → `POST
/api/v1/exceptions/{id}/finalize` → **200 approved** → re-`PUT` approve `t-1`
→ **201** (gate lifted).
- **C. DECOY (#2)** — same flow on `t-3` with `EXC-WILL-REIMBURSE` files +
finalizes (**201/200**) but the approve stays **422 `OVER_POLICY_LIMIT`**.
- **D. CATALOGUE (#2)** — `POST /api/v1/exceptions {code:"EXC-…NOT-REAL"}` →
**422 `INVALID_EXCEPTION_CODE`**, and the body does **not** enumerate any real
catalogue codes (non-enumeration invariant).
> The store is in-memory and seeded from `src/data/seed.json`. Each scenario uses
> a different seeded over-limit transaction (`t-1`, `t-3`, `t-2`), so one run
> needs no reset. To re-run from scratch, **restart the dev server** to reseed.
Minimal manual equivalent of the gate→unlock payoff:
```bash
BASE=http://localhost:3939/api/v1
# A. gate blocks
curl -s -X PUT "$BASE/transactions/t-1" -H 'content-type: application/json' \
-d '{"status":"approved"}' # -> 422 OVER_POLICY_LIMIT
# B. unlock
EXC=$(curl -s -X POST "$BASE/exceptions" -H 'content-type: application/json' \
-d '{"transactionId":"t-1","code":"EXC-BOARD-APPROVED"}' | jq -r .id)
curl -s -X POST "$BASE/exceptions/$EXC/finalize" # -> 200 approved
curl -s -X PUT "$BASE/transactions/t-1" -H 'content-type: application/json' \
-d '{"status":"approved"}' # -> 201 (now allowed)
```
### Fresh-agent learning proof (activates once the backend lands) — roles #3 + #5
This is the proof that the loop _learned_, not that the REST works. It requires
the recording hook (real, not the shim) and the env-gated `CopilotKitIntelligence`
runtime configured (`INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`,
`INTELLIGENCE_API_KEY`).
1. **Baseline (no knowledge).** In a fresh thread, ask the agent to approve an
over-limit transaction. With role #4 framing intact it **fails correctly**:
it hits the gate, has no procedure, and (per ACTION DISCIPLINE) reports the
failure instead of firing a distractor. _This failure is the control._
2. **Human teaches.** A human opens the policy-exception modal and performs the
unlock (justifying code → finalize). Each step fires `recordUserAction(...)`
on the current thread (now a real stream, not a no-op).
3. **Distill.** The Intelligence writer agent distills those recorded actions
into a reusable procedure in `/knowledge`.
4. **Fresh agent succeeds unaided.** In a **new** thread (no memory of the
human's session), ask the same over-limit approval. The agent retrieves
`/knowledge`, files a _justifying_ exception, finalizes it, and the approval
now returns **201** — with **no human help and nothing added to the prompt**.
Pass criteria: step 1 fails, step 4 succeeds, and the only thing that changed
between them is the distilled `/knowledge`. That delta _is_ the learning.
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
#
# verify-teachable-gate.sh — proves the teach-mode GATE → UNLOCK contract end to
# end against the BANKING demo's real REST routes. This is the BACKEND-INDEPENDENT
# proof: it exercises roles #1 (GATE), #2 (UNLOCK + DECOY + catalogue check) of
# the 5-role contract entirely over HTTP, with NO Intelligence stack required.
# (Roles #3 RECORDING / #4 AGENT FRAMING / #5 KNOWLEDGE BACKEND are proven
# separately — see the README "Verification" section for the fresh-agent learning
# proof that activates once the backend lands.)
#
# It demonstrates, in order:
# A. GATE — approving an over-policy-limit transaction is blocked (422
# OVER_POLICY_LIMIT, symptom-only: it names the problem, never
# the fix).
# B. UNLOCK — filing a JUSTIFYING policy exception (open → finalize → link)
# lifts the gate; the same approval now succeeds.
# C. DECOY — a NON-justifying catalogue code files fine but does NOT lift
# the gate; approval stays blocked (422).
# D. CATALOGUE — an INVALID code is rejected (422 INVALID_EXCEPTION_CODE)
# WITHOUT the response enumerating the valid catalogue.
#
# USAGE
# ./verify-teachable-gate.sh # against http://localhost:3939
# BASE_URL=http://localhost:3000 ./verify-teachable-gate.sh
# ./verify-teachable-gate.sh http://localhost:3000 # positional override
#
# PREREQUISITES
# - The banking demo running locally (its dev server, e.g. `pnpm dev`). NOTE:
# `next dev` defaults to :3000; this script defaults to :3939 to match the
# project convention — point BASE_URL at whatever port you actually serve.
# - `curl` and `jq` on PATH.
# - A FRESH server process (or one not yet mutated by a prior run). The store is
# in-memory and seeded from `src/data/seed.json`; restart the dev server to
# reset. Each scenario below uses a DIFFERENT seeded transaction so a single
# run does not need a reset between scenarios.
#
# SEED FACTS THIS SCRIPT RELIES ON (src/data/seed.json):
# - t-1 "Google Ads" amount -5000 policy Marketing (limit 5000, spent 500)
# → approve needs 500+5000=5500 > 5000 ⇒ OVER LIMIT (scenario A/B)
# - t-3 "Microsoft 365" amount -10000 policy Executive (limit 10000, spent 1000)
# → approve needs 1000+10000=11000 > 10000 ⇒ OVER LIMIT (scenario C)
# - t-2 "AWS" amount -15000 policy Engineering(limit 15000, spent 1500)
# → approve needs 1500+15000=16500 > 15000 ⇒ OVER LIMIT (scenario D)
#
# JUSTIFYING vs DECOY codes (src/app/api/v1/policy-exception-codes.ts):
# JUSTIFYING (lift the gate): EXC-BOARD-APPROVED, EXC-CONTRACTUAL-COMMITMENT,
# EXC-EMERGENCY-SPEND
# DECOY (filed for history, do NOT lift the gate): EXC-WILL-REIMBURSE, EXC-ONE-TIME
# INVALID (not in the catalogue at all): anything else, e.g. EXC-MADE-UP
#
set -euo pipefail
# --- Config ----------------------------------------------------------------
BASE_URL="${1:-${BASE_URL:-http://localhost:3939}}"
API="${BASE_URL%/}/api/v1"
# Seeded over-limit transactions (one per scenario so they don't interfere).
TXN_GATE="t-1" # scenarios A + B
TXN_DECOY="t-3" # scenario C
TXN_INVALID="t-2" # scenario D
JUSTIFYING_CODE="EXC-BOARD-APPROVED" # lifts the gate (role #2 UNLOCK)
DECOY_CODE="EXC-WILL-REIMBURSE" # valid catalogue code, does NOT justify
INVALID_CODE="EXC-DEFINITELY-NOT-REAL" # not in the catalogue at all
# --- Pretty helpers --------------------------------------------------------
have() { command -v "$1" >/dev/null 2>&1; }
if ! have curl; then echo "ERROR: curl not found on PATH" >&2; exit 1; fi
if ! have jq; then echo "ERROR: jq not found on PATH (used to parse JSON)" >&2; exit 1; fi
PASS=0
FAIL=0
section() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); }
# status_of METHOD URL [JSON_BODY] -> prints HTTP status, stashes body in $BODY
BODY=""
status_of() {
local method="$1" url="$2" data="${3:-}"
local resp code
if [[ -n "$data" ]]; then
resp="$(curl -sS -o /tmp/tm_body.$$ -w '%{http_code}' \
-X "$method" "$url" \
-H 'content-type: application/json' \
-d "$data")"
else
resp="$(curl -sS -o /tmp/tm_body.$$ -w '%{http_code}' -X "$method" "$url")"
fi
code="$resp"
BODY="$(cat /tmp/tm_body.$$ 2>/dev/null || true)"
rm -f /tmp/tm_body.$$
printf '%s' "$code"
}
approve() { status_of PUT "$API/transactions/$1" '{"status":"approved"}'; }
open_exception() { status_of POST "$API/exceptions" "{\"transactionId\":\"$1\",\"code\":\"$2\"}"; }
finalize() { status_of POST "$API/exceptions/$1/finalize"; }
printf 'teach-mode gate→unlock verification\n'
printf 'BASE_URL = %s\n' "$BASE_URL"
# ===========================================================================
# Scenario A — GATE (role #1): approving an over-limit txn is blocked, 422,
# symptom-only. The error names the over-limit POLICY but NEVER the
# policy-exception path that would lift it.
# Route: PUT /api/v1/transactions/[id] (src/app/api/v1/transactions/[id]/route.ts)
# ===========================================================================
section "A. GATE — over-limit approval is blocked (role #1)"
code="$(approve "$TXN_GATE")"
if [[ "$code" == "422" && "$(jq -r '.error' <<<"$BODY")" == "OVER_POLICY_LIMIT" ]]; then
ok "approve $TXN_GATE → 422 OVER_POLICY_LIMIT"
else
bad "expected 422 OVER_POLICY_LIMIT, got HTTP $code body=$BODY"
fi
# Symptom-only invariant: the rejection must NOT leak the unlock recipe.
if grep -qiE 'exception|EXC-|finalize|override|knowledge' <<<"$BODY"; then
bad "GATE error leaked the fix (mentions the exception/unlock path): $BODY"
else
ok "GATE error is symptom-only (no mention of the exception/unlock recipe)"
fi
# ===========================================================================
# Scenario B — UNLOCK (role #2): file a JUSTIFYING exception, then approve.
# open → POST /api/v1/exceptions (src/app/api/v1/exceptions/route.ts)
# final → POST /api/v1/exceptions/[id]/finalize
# (src/app/api/v1/exceptions/[id]/finalize/route.ts)
# The finalize step auto-approves the exception AND links it to the
# transaction's activeExceptionId — which is what lifts the gate, but ONLY
# because the code is justifying (store.hasApprovedException + isJustifying).
# ===========================================================================
section "B. UNLOCK — justifying exception lifts the gate (role #2)"
code="$(open_exception "$TXN_GATE" "$JUSTIFYING_CODE")"
if [[ "$code" == "201" ]]; then
EXC_ID="$(jq -r '.id' <<<"$BODY")"
ok "open exception ($JUSTIFYING_CODE) on $TXN_GATE → 201 id=$EXC_ID"
else
bad "expected 201 opening exception, got HTTP $code body=$BODY"; EXC_ID=""
fi
if [[ -n "${EXC_ID:-}" ]]; then
code="$(finalize "$EXC_ID")"
if [[ "$code" == "200" && "$(jq -r '.status' <<<"$BODY")" == "approved" ]]; then
ok "finalize $EXC_ID → 200 status=approved (linked to $TXN_GATE)"
else
bad "expected 200 approved finalizing, got HTTP $code body=$BODY"
fi
fi
# The payoff: the SAME approval that was blocked in A now succeeds.
code="$(approve "$TXN_GATE")"
if [[ "$code" == "201" ]]; then
ok "approve $TXN_GATE again → 201 (gate lifted by justifying exception)"
else
bad "expected 201 after justifying unlock, got HTTP $code body=$BODY"
fi
# ===========================================================================
# Scenario C — DECOY (role #2 invariant): a NON-justifying catalogue code is a
# valid code (files + finalizes fine, recorded for history) but does NOT lift
# the gate. Approval must stay 422. This is what forces the agent to LEARN
# WHICH codes justify — it can file every code and still fail.
# ===========================================================================
section "C. DECOY — non-justifying code files but does NOT unlock (role #2)"
code="$(open_exception "$TXN_DECOY" "$DECOY_CODE")"
if [[ "$code" == "201" ]]; then
DECOY_ID="$(jq -r '.id' <<<"$BODY")"
ok "open exception ($DECOY_CODE) on $TXN_DECOY → 201 id=$DECOY_ID (accepted: valid code)"
else
bad "expected 201 opening decoy exception, got HTTP $code body=$BODY"; DECOY_ID=""
fi
if [[ -n "${DECOY_ID:-}" ]]; then
code="$(finalize "$DECOY_ID")"
if [[ "$code" == "200" ]]; then
ok "finalize $DECOY_ID → 200 (filed for history)"
else
bad "expected 200 finalizing decoy, got HTTP $code body=$BODY"
fi
fi
code="$(approve "$TXN_DECOY")"
if [[ "$code" == "422" && "$(jq -r '.error' <<<"$BODY")" == "OVER_POLICY_LIMIT" ]]; then
ok "approve $TXN_DECOY → 422 OVER_POLICY_LIMIT (decoy did NOT lift the gate)"
else
bad "expected 422 OVER_POLICY_LIMIT (decoy must not unlock), got HTTP $code body=$BODY"
fi
# ===========================================================================
# Scenario D — CATALOGUE CHECK (role #2 invariant): an INVALID code is rejected
# (422 INVALID_EXCEPTION_CODE) WITHOUT enumerating the valid catalogue. Leaking
# the list would itself be a hint; the agent must discover the catalogue via
# /knowledge. Rejection happens at open time, so nothing to finalize/approve.
# ===========================================================================
section "D. CATALOGUE — invalid code rejected without leaking the list (role #2)"
code="$(open_exception "$TXN_INVALID" "$INVALID_CODE")"
if [[ "$code" == "422" && "$(jq -r '.error' <<<"$BODY")" == "INVALID_EXCEPTION_CODE" ]]; then
ok "open exception ($INVALID_CODE) → 422 INVALID_EXCEPTION_CODE"
else
bad "expected 422 INVALID_EXCEPTION_CODE, got HTTP $code body=$BODY"
fi
# Non-enumeration invariant: the rejection must not list any real catalogue codes.
if grep -qE 'EXC-BOARD-APPROVED|EXC-CONTRACTUAL-COMMITMENT|EXC-EMERGENCY-SPEND|EXC-WILL-REIMBURSE|EXC-ONE-TIME' <<<"$BODY"; then
bad "INVALID_EXCEPTION_CODE response leaked catalogue codes: $BODY"
else
ok "rejection does not enumerate the catalogue (no valid codes leaked)"
fi
# --- Summary ---------------------------------------------------------------
section "Summary"
printf ' passed: %d failed: %d\n' "$PASS" "$FAIL"
if [[ "$FAIL" -gt 0 ]]; then
printf '\n\033[31mGATE→UNLOCK contract NOT satisfied.\033[0m If failures are 404s or\n'
printf 'unexpected 201s on scenario A/C, you are likely re-running against a server\n'
printf 'whose store was already mutated — restart the dev server to reseed.\n'
exit 1
fi
printf '\n\033[32mGATE→UNLOCK contract verified (roles #1 and #2).\033[0m\n'
@@ -0,0 +1,102 @@
import { test, expect } from "@playwright/test";
/**
* A2UI report canvas E2E.
*
* Proves the render_report → a2ui-surface → full-screen canvas handoff: an
* assistant turn calls the backend `render_report` tool with a small selection
* (which KPIs/charts); the tool builds A2UI ops, the A2UIMiddleware turns them
* into an `a2ui-surface` activity, the chat shows a "rendered on the canvas"
* pill, and LayoutComponent swaps the dashboard body out for <ReportCanvas/>
* (data-testid="a2ui-surface"), which reads the ops from the agent message
* stream and renders them against the banking catalog with live client data.
*
* ── HOW render_report BECOMES AN a2ui-surface ACTIVITY (verified) ────────────────
* The runtime sets `a2ui: { injectA2UITool: false }` on both variants
* (src/app/api/copilotkit/[[...slug]]/route.ts) and registers a backend tool
* `render_report` on the BuiltInAgent whose `execute` returns
* `{ a2ui_operations: [...] }` (built deterministically by
* src/a2ui/build-report-ops.ts). With injectA2UITool:false the middleware
* detects that `a2ui_operations` container in the tool result
* (tryParseA2UIOperations) and emits the a2ui-surface activity. The model only
* emits the tiny render_report selection — it never authors component JSON.
*
* ── REQUIRED aimock FIXTURE (not yet wired — see test.fixme below) ───────────────
* aimock mocks ONLY the LLM, so the fixture just emits the render_report tool
* call; the real backend `execute` runs and builds the ops. It must be loaded by
* the aimock server (e2e/aimock-server.mjs), which currently hardcodes only
* fixtures/memory-learning.fixtures.json — wiring this is the remaining step:
*
* {
* "match": { "userMessage": "spend report on the canvas", "turnIndex": 0 },
* "response": { "toolCalls": [{
* "name": "render_report",
* "arguments": {
* "title": "Spend report",
* "kpis": ["totalSpend", "pendingCount"],
* "charts": ["spendingTrend"],
* "transactions": "pending"
* }
* }] }
* },
* {
* "match": { "userMessage": "spend report on the canvas", "turnIndex": 1 },
* "response": { "content": "I put a spend report on the canvas." }
* }
*
* (memory-learning.fixtures.json uses `sequenceIndex`; render-a2ui fixtures under
* showcase/aimock use `turnIndex` + `hasToolResult`. Both are accepted by
* loadFixtureFile — confirm the matching key on first run.)
*
* ── WHY test.fixme (headless CI honesty) ─────────────────────────────────────────
* This gate needs the aimock stack running with the fixture above loaded
* (Playwright webServer[0] currently loads only the memory fixtures) plus the
* banking dev server. The fixture wiring and a first green run have not been
* confirmed here, so — per the plan, where the aimock E2E is optional and MUST
* NOT block the feature — it is marked test.fixme so it never reds the suite.
* Remove `.fixme` once e2e/aimock-server.mjs loads this fixture and it passes
* locally. (The render_report path itself is verified manually against a live
* LLM: the report prompt paints KPIs + charts on the canvas.)
*/
const REPORT_PROMPT =
"Build a spend report on the canvas: show the spending trend.";
test.describe("A2UI report canvas", () => {
test.fixme("a report prompt renders the A2UI surface on the canvas with a handoff pill", async ({
page,
}) => {
await page.goto("/");
// The dashboard body (credit-cards page) is visible before any surface is
// active — this is the "before" state we assert disappears.
await expect(
page.getByRole("heading", { name: "Credit Cards", level: 1 }),
).toBeVisible();
// Open the docked chat (starts closed; v2 launcher has this testid).
await page.getByTestId("copilot-chat-toggle").click();
// Send a report prompt. "spend report on the canvas" is the aimock match key.
const input = page.getByPlaceholder(/type a message/i);
await input.fill(REPORT_PROMPT);
await input.press("Enter");
// The chat shows the handoff pill (status-only a2ui-surface renderer in
// wrapper.tsx).
await expect(page.getByText(/rendered on the canvas/i)).toBeVisible({
timeout: 30_000,
});
// The surface renders on the full-screen canvas (report-canvas.tsx).
await expect(page.getByTestId("a2ui-surface")).toBeVisible({
timeout: 30_000,
});
// While the surface is active the normal dashboard body is swapped out for
// <ReportCanvas/> (layout.tsx: activeSurfaceId ? <ReportCanvas/> : children).
await expect(
page.getByRole("heading", { name: "Credit Cards", level: 1 }),
).toHaveCount(0);
});
});
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* aimock launcher for the deterministic banking E2Es.
*
* Starts an aimock server on $AIMOCK_PORT (default 7099) so the banking dev server
* can point OPENAI_BASE_URL at it and get deterministic agent tool calls. The
* fixture file is $AIMOCK_FIXTURES (a path relative to cwd, or absolute); when
* unset it defaults to fixtures/memory-learning.fixtures.json. The OGUI routing
* suite (playwright.ogui.config.ts) sets AIMOCK_FIXTURES=fixtures/ogui-routing...
* on its own aimock instance (port 7098).
*
* Used as a Playwright webServer entry — Playwright waits on the readiness URL
* before starting the dev server.
*
* VERIFY ON FIRST GREEN RUN (unverified API assumptions):
* - The exact @copilotkit/aimock programmatic API. This uses the documented
* `loadFixtureFile` + a server factory. If the named exports differ, the
* simplest robust fallback is the bundled CLI instead of this script, e.g.:
* pnpm exec aimock --port 7099 --validate-on-load e2e/fixtures/memory-learning.fixtures.json
* (confirm the CLI's fixture-path flag; `aimock --help`). If you switch to the
* CLI, set playwright.config webServer[0].command accordingly and delete this file.
* - The readiness endpoint path (this assumes GET /health returns 200 once ready).
*/
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const PORT = Number(process.env.AIMOCK_PORT ?? 7099);
const FIXTURES = process.env.AIMOCK_FIXTURES
? process.env.AIMOCK_FIXTURES.startsWith("/")
? process.env.AIMOCK_FIXTURES
: join(process.cwd(), process.env.AIMOCK_FIXTURES)
: join(
dirname(fileURLToPath(import.meta.url)),
"fixtures",
"memory-learning.fixtures.json",
);
const mod = await import("@copilotkit/aimock");
// Preferred path: load + validate the fixture file, then start a server bound to it.
// The exact factory name is the main thing to confirm; we try the documented ones.
// Confirmed exports in @copilotkit/aimock@1.19.1: LLMock, loadFixtureFile,
// validateFixtures, createServer. LLMock is the OpenAI-shape mock server.
const loadFixtureFile = mod.loadFixtureFile ?? mod.default?.loadFixtureFile;
const validateFixtures = mod.validateFixtures ?? mod.default?.validateFixtures;
const ServerCtor = mod.LLMock ?? mod.default?.LLMock;
if (!ServerCtor) {
console.error(
"[aimock-server] Could not find a server constructor in @copilotkit/aimock.\n" +
"Use the bundled CLI instead (see header): pnpm exec aimock --port " +
PORT +
" --validate-on-load " +
FIXTURES,
);
process.exit(2);
}
// loadFixtureFile returns a ready-to-use array of converted Fixture objects
// (it parses the {fixtures:[...]} file and applies entryToFixture).
const loadedFixtures = loadFixtureFile
? loadFixtureFile(FIXTURES)
: (JSON.parse(
await (await import("node:fs/promises")).readFile(FIXTURES, "utf8"),
).fixtures ?? []);
if (validateFixtures) validateFixtures(loadedFixtures);
// NOTE: LLMock's constructor does NOT read `options.fixtures` — fixtures passed
// that way are silently dropped and the server starts with zero fixtures (so
// every LLM turn 404s "No fixture matched" and the agent never advances). They
// MUST be registered via addFixtures()/addFixture(). (Verified against
// @copilotkit/aimock@1.19.1: `new LLMock({fixtures}).getFixtures().length === 0`.)
const server = new ServerCtor({ port: PORT });
server.addFixtures(loadedFixtures);
await server.start();
console.log(
`[aimock-server] listening on :${PORT} with ${server.getFixtures().length} fixtures`,
);
const shutdown = async () => {
try {
await server.stop?.();
} finally {
process.exit(0);
}
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
@@ -0,0 +1,83 @@
{
"_comment": [
"aimock fixtures for the deterministic cross-thread memory proof (Task 7 / FOR-149).",
"These pin the agent's LLM decisions so the test exercises the REAL local memory",
"backend (recall ranking + tenant scoping) without LLM nondeterminism.",
"",
"STRATEGY (recall-half determinism, per the plan's Task 7 option 2): the spec SEEDS the",
"over-limit procedure as a project/operational memory via REST, then drives a FRESH",
"thread. With the procedure already in memory, the agent must (1) recall_memory, then",
"apply the recalled code by (2) openPolicyException -> (3) finalizePolicyException ->",
"(4) approveTransaction, and never offer to record. Each step is one fixture, matched in",
"order via sequenceIndex (the same user message drives the whole run).",
"",
"VERIFY ON FIRST GREEN RUN (these are the unverified assumptions):",
"- Fixture file schema: top-level {fixtures:[...]}, each {match:{userMessage, sequenceIndex?}, response:{content|toolCalls}}.",
" Confirm against `loadFixtureFile`/`validateFixtures` from @copilotkit/aimock and showcase/aimock/*.json.",
"- Multi-turn ordering key: this uses `sequenceIndex`. If aimock uses a different key",
" (e.g. turnIndex / hasToolResult), rename accordingly (see showcase/GOTCHAS.md).",
"- Tool names/argument shapes must match the banking tools (openPolicyException,",
" finalizePolicyException, approveTransaction) and the agent's recall_memory call.",
"- EXC-BOARD-APPROVED must stay the seeded justifying code (see the spec + over-limit-gate-smoke)."
],
"fixtures": [
{
"match": { "userMessage": "over-limit", "sequenceIndex": 0 },
"response": {
"toolCalls": [
{
"name": "recall_memory",
"arguments": {
"query": "how to approve an over-limit charge / policy exception procedure"
}
}
]
}
},
{
"match": { "userMessage": "over-limit", "sequenceIndex": 1 },
"response": {
"toolCalls": [
{
"name": "openPolicyException",
"arguments": {
"transactionId": "t-3",
"code": "EXC-BOARD-APPROVED"
}
}
]
}
},
{
"match": { "userMessage": "over-limit", "sequenceIndex": 2 },
"response": {
"toolCalls": [
{
"name": "finalizePolicyException",
"arguments": {
"transactionId": "t-3",
"code": "EXC-BOARD-APPROVED"
}
}
]
}
},
{
"match": { "userMessage": "over-limit", "sequenceIndex": 3 },
"response": {
"toolCalls": [
{
"name": "approveTransaction",
"arguments": { "transactionId": "t-3" }
}
]
}
},
{
"match": { "userMessage": "over-limit", "sequenceIndex": 4 },
"response": {
"content": "Done — I recalled the saved procedure, filed an EXC-BOARD-APPROVED policy exception, and approved the over-limit charge."
}
}
]
}
@@ -0,0 +1,91 @@
{
"_comment": [
"aimock fixtures for the deterministic OGUI routing test.",
"Single-turn: each pill message maps to the tool the agent must pick.",
"Guards BOTH boundary sides — curated pills must NOT route to generateSandboxedUi,",
"OGUI pills MUST. userMessage values are copied byte-for-byte from wrapper.tsx pill messages."
],
"fixtures": [
{
"match": { "userMessage": "Show me the spending trend over time." },
"response": {
"toolCalls": [{ "name": "showSpendingTrend", "arguments": {} }]
}
},
{
"match": {
"userMessage": "Show me budget usage by policy — which ones are close to or over their limit?"
},
"response": {
"toolCalls": [{ "name": "showBudgetUsage", "arguments": {} }]
}
},
{
"match": {
"userMessage": "Where is the money going? Break down spend by team."
},
"response": {
"toolCalls": [{ "name": "showSpendBreakdown", "arguments": {} }]
}
},
{
"match": {
"userMessage": "Compare our income vs expenses — how is our cash flow?"
},
"response": {
"toolCalls": [{ "name": "showIncomeVsExpenses", "arguments": {} }]
}
},
{
"match": {
"userMessage": "Build a full spend report on the canvas: KPIs, the spending trend, budget usage, and a spend breakdown by team."
},
"response": {
"toolCalls": [
{
"name": "render_report",
"arguments": {
"title": "Spend report",
"kpis": ["totalSpend"],
"charts": ["spendingTrend"]
}
}
]
}
},
{
"match": {
"userMessage": "Build an interactive spend explorer I can filter and play with — pull the real transactions and policies."
},
"response": {
"toolCalls": [
{
"name": "generateSandboxedUi",
"arguments": {
"initialHeight": 320,
"html": "<div id=\"root\">Spend explorer</div>",
"jsFunctions": "async function load(){ const t = await window.Websandbox.connection.remote.getTransactions(); document.getElementById('root').textContent = 'txns: ' + t.length; } load();"
}
}
]
}
},
{
"match": {
"userMessage": "Prototype an interactive what-if calculator for our cash flow using our real income and expense figures."
},
"response": {
"toolCalls": [
{
"name": "generateSandboxedUi",
"arguments": {
"initialHeight": 320,
"html": "<div id=\"calc\">What-if calculator</div>",
"jsFunctions": "async function load(){ const k = await window.Websandbox.connection.remote.getKpis(); document.getElementById('calc').textContent = 'spend: ' + k.totalSpend; } load();"
}
}
]
}
}
]
}
@@ -0,0 +1,218 @@
import { test, expect } from "@playwright/test";
/**
* Deterministic cross-thread memory proof (Task 7 / FOR-149).
*
* Proves the RECALL half deterministically: with the over-limit procedure already
* in project memory, a FRESH thread recalls it and completes the unlock unaided —
* never offering to record. The agent's LLM is served by aimock (fixtures pin the
* recall_memory -> openPolicyException -> finalizePolicyException ->
* approveTransaction sequence), while the REAL local Intelligence backend does the
* actual recall ranking + tenant scoping.
*
* The SAVE half is HITL+LLM (Option A) and is covered by the manual walkthrough
* (README) + scripts/memory-drift-smoke.mjs — not this gate.
*
* ── PRECONDITIONS (the gate assumes these are already running) ──────────────────
* 1. The memory stack is up and healthy (docker compose; see README). app-api on
* $APP_API_URL (default http://localhost:7050) with the seeded org/key. IMPORTANT:
* "container healthy" is not sufficient — the sl-mcp memory worker can throw an
* UnhandledPromiseRejection during boot and briefly drop /mcp connections. If this
* test fails with "No fixture matched" plus app-side [MCPMiddleware] "Failed to list
* tools" / "other side closed" / ECONNRESET on :7050, that is the backend startup
* window, NOT a fixture or demo bug — wait until `POST /mcp initialize` returns 200
* (see scripts/memory-*-smoke.mjs readiness gate) and re-run. When the memory tools
* fail to attach, the agent skips recall_memory and its LLM-call sequence diverges
* from the sequenced fixtures, so the mismatch surfaces on a later call.
* 2. Playwright starts aimock (webServer[0]) + the dev server in Intelligence mode
* with OPENAI_BASE_URL pointed at aimock (see playwright.config.ts). Runs are
* sequenced against one aimock server; parallel workers can interleave the
* over-limit fixture group's shared counter, so run this spec with --workers=1.
*
* ── VERIFY ON FIRST GREEN RUN (unverified assumptions to shake out) ─────────────
* - Chat input + send selectors (getByPlaceholder/getByRole below) match the
* CopilotSidebar markup.
* - The recall-path HITL cards (openPolicyException / finalizePolicyException /
* approveTransaction) render approve buttons; this clicks any visible
* approve/confirm button to advance. Adjust the APPROVE_LABELS list to the real
* ApprovalButtons label(s).
* - The over-limit seed txn id (DRIFT/GATE txn) is t-3 and renders a status that
* reads "cleared"/"approved" after the flow.
* - The fixtures' multi-turn ordering key (sequenceIndex) matches aimock.
*/
const APP_API_URL = process.env.APP_API_URL ?? "http://localhost:7050";
const KEY =
process.env.INTELLIGENCE_API_KEY ?? "cpk_sPRVSEED_seed0privat0longtoken00";
const USER_ID = process.env.CPKI_USER_ID ?? "jordan-beamson";
const TXN_ID = process.env.GATE_TXN_ID ?? "t-3";
const SEED_CODE = "EXC-BOARD-APPROVED";
const APPROVE_LABELS = [
/^approve$/i,
/^confirm$/i,
/^yes$/i,
/^approve transaction$/i,
];
const memHeaders = {
Authorization: `Bearer ${KEY}`,
"X-Cpki-User-Id": USER_ID,
"Content-Type": "application/json",
};
async function recallProcedureIds(): Promise<string[]> {
const res = await fetch(`${APP_API_URL}/api/memories/recall`, {
method: "POST",
headers: memHeaders,
body: JSON.stringify({
query: "over-limit approval procedure",
scope: "project",
}),
});
if (!res.ok) return [];
const body = (await res.json()) as { memories?: { id: string }[] };
return (body.memories ?? []).map((m) => m.id);
}
/** Arrange a clean slate, then seed exactly one project/operational procedure. */
async function resetAndSeedProcedure(): Promise<void> {
for (const id of await recallProcedureIds()) {
await fetch(`${APP_API_URL}/api/memories/${id}`, {
method: "DELETE",
headers: memHeaders,
});
}
const res = await fetch(`${APP_API_URL}/api/memories`, {
method: "POST",
headers: memHeaders,
body: JSON.stringify({
content:
`To approve an over-limit charge, open a policy exception with code ${SEED_CODE} ` +
`against the charge and finalize it, then approve the transaction.`,
scope: "project",
kind: "operational",
}),
});
expect(res.status, "seed procedure memory").toBe(201);
}
test.describe("durable cross-thread memory recall (FOR-149)", () => {
test.beforeAll(async () => {
await resetAndSeedProcedure();
});
test("a fresh thread recalls the procedure and unlocks the over-limit charge unaided", async ({
page,
}) => {
await page.goto("/");
// The docked chat starts closed (clean-dashboard first impression); open it
// before interacting. CopilotSidebar's launcher is labelled "Open chat".
const openChat = page.getByRole("button", { name: /open chat/i });
if (await openChat.count()) await openChat.first().click();
// Fresh thread so there is NO in-thread context — recall is the only way the
// agent can know the procedure.
const newThread = page.getByRole("button", {
name: /new (thread|conversation|chat)/i,
});
if (await newThread.count()) await newThread.first().click();
// Send an over-limit approval request. "over-limit" matches the aimock fixtures.
const input = page.getByPlaceholder(/type a message/i);
await input.fill(`Please approve the over-limit charge ${TXN_ID}.`);
await input.press("Enter");
// The agent (via aimock) recalls then drives openPolicyException ->
// finalizePolicyException -> approveTransaction, each as a HITL approval card.
// Click through them; assert the recording offer never appears.
const recordOffer = page.getByText(/record a workflow\?/i);
for (let step = 0; step < 4; step++) {
// The "Record a workflow?" card must never appear on the recall path.
await expect(recordOffer).toHaveCount(0);
// Advance whichever approval card is currently shown.
const approve = page.getByRole("button", { name: APPROVE_LABELS[0] });
await approve
.first()
.click({ timeout: 30_000 })
.catch(async () => {
for (const label of APPROVE_LABELS.slice(1)) {
const b = page.getByRole("button", { name: label });
if (await b.count()) return b.first().click();
}
});
}
// Outcome: the recording offer never appeared, and the charge is cleared. Prefer
// a server assertion (robust) — the over-limit gate is now lifted for TXN_ID.
await expect(recordOffer).toHaveCount(0);
await expect
.poll(
async () => {
const res = await fetch(
`http://localhost:3000/api/v1/transactions/${TXN_ID}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "approved" }),
},
);
return res.status;
},
{
timeout: 30_000,
message:
"over-limit charge should be approvable after recalled unlock",
},
)
// 201 = approve succeeded (gate lifted); 409/200 = already approved by the run.
.not.toBe(422);
});
test("Glass Engine inspector streams events and shows the learned procedure", async ({
page,
}) => {
await page.goto("/");
// Open advanced mode from the left rail (availability gate is set in
// playwright.config.ts via GLASS_ENGINE_AVAILABLE=true).
await page
.getByRole("button", { name: /glass engine/i })
.first()
.click();
// The inspector pane is visible with its three tabs.
await expect(page.getByText(/live protocol inspector/i)).toBeVisible();
await expect(page.getByRole("tab", { name: /timeline/i })).toBeVisible();
// Open the docked chat (starts closed) and drive a run so the Timeline
// receives AG-UI events.
const openChat = page.getByRole("button", { name: /open chat/i });
if (await openChat.count()) await openChat.first().click();
const input = page.getByPlaceholder(/type a message/i);
await input.fill(`Please approve the over-limit charge ${TXN_ID}.`);
await input.press("Enter");
// Timeline shows at least one streamed card (the empty-state copy is gone).
await expect(page.getByText(/every AG-UI protocol event/i)).toHaveCount(0, {
timeout: 30_000,
});
// Learning tab reflects the seeded project procedure as "learned".
await page.getByRole("tab", { name: /learning/i }).click();
await expect(page.getByText(/over-limit procedure — learned/i)).toBeVisible(
{
timeout: 30_000,
},
);
// Memory tab lists the seeded procedure content under "Recalled memories".
await page.getByRole("tab", { name: /memory/i }).click();
await expect(
page.getByText(/policy exception with code/i).first(),
).toBeVisible({
timeout: 30_000,
});
});
});
@@ -0,0 +1,115 @@
import { test, expect, type Page } from "@playwright/test";
// Deterministic (aimock-backed) routing guard for the adjacency set — the pills
// OGUI could plausibly steal. Curated pills must NOT open an OGUI iframe; OGUI
// pills must. Runs in OSS mode via playwright.ogui.config.ts (isolated ports).
//
// NUANCE: clicking a pill sends its `message` (not its `title`); aimock matches
// on userMessage = that message (see e2e/fixtures/ogui-routing.fixtures.json).
// Here we click by the pill TITLE (what the user sees). Keep these straight.
async function openChatAndClick(page: Page, pillText: string) {
await page.goto("/");
// The docked chat starts closed; CopilotSidebar's launcher is "Open chat".
const launcher = page.getByRole("button", { name: /open chat/i });
if (await launcher.count()) await launcher.first().click();
// Wait for the chat to hydrate (input present) before clicking a pill, else
// the click can land before React wires the pill's onClick and is dropped.
await expect(page.getByPlaceholder(/type a message/i)).toBeVisible({
timeout: 15_000,
});
const pill = page
.getByTestId("copilot-suggestion")
.filter({ hasText: pillText })
.first();
// The docked chat panel is pinned to the right edge, so its suggestion pills
// land outside the viewport where Playwright's click (even force:true)
// refuses to fire. Call the element's native click() — this drives React's
// onClick (which sends the pill's `message` to the agent) regardless of
// viewport position. Retry until the send registers (a user message bubble
// appears), covering the race where an early click is dropped before
// hydration completes.
await expect(async () => {
await pill.evaluate((el) => (el as HTMLElement).click());
await expect(page.getByTestId("copilot-user-message").first()).toBeVisible({
timeout: 2_000,
});
}).toPass({ timeout: 20_000 });
}
const CURATED = [
{ pill: "Show the spending trend", heading: /spending trend/i },
{ pill: "Budgets near their limit?", heading: /budget usage/i },
{ pill: "Where is the money going?", heading: /spend breakdown/i },
{ pill: "How's our cash flow?", heading: /income vs expenses/i },
];
test.describe("OGUI routing — adjacency set", () => {
for (const { pill, heading } of CURATED) {
test(`curated pill "${pill}" renders its chart, not an OGUI iframe`, async ({
page,
}) => {
await openChatAndClick(page, pill);
// The curated chart renders inside an assistant message as an <h3> card
// title. Scope to the transcript's assistant messages (not the pill row,
// whose titles also contain these words). Match on the <h3> text rather
// than role="heading": CopilotKit paints rendered tool output with
// pointer-events/aria affordances that can drop the heading from the
// accessibility tree, so getByRole("heading") is unreliable here.
await expect(
page
.getByTestId("copilot-assistant-message")
.locator("h3")
.filter({ hasText: heading })
.first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("ogui-surface")).toHaveCount(0);
await expect(page.locator("iframe")).toHaveCount(0);
});
}
test('boundary: "Build a spend report on the canvas" routes to render_report, not OGUI', async ({
page,
}) => {
await openChatAndClick(page, "Build a spend report on the canvas");
await expect(page.getByTestId("a2ui-surface")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("ogui-surface")).toHaveCount(0);
});
const OGUI = [
"Build an interactive spend explorer",
"Prototype a cash-flow what-if calculator",
];
for (const pill of OGUI) {
test(`OGUI pill "${pill}" renders on the canvas`, async ({ page }) => {
await openChatAndClick(page, pill);
const surface = page.getByTestId("ogui-surface");
await expect(surface).toBeVisible({ timeout: 30_000 });
await expect(surface.locator("iframe").first()).toBeVisible();
// Two identical handoff pills appear on OGUI turns in REPLAY (this uses
// .first() to tolerate that). Cause: generateSandboxedUi is a frontend tool
// with followUp:true, so after it runs the agent takes a follow-up turn with
// the SAME (unchanged) userMessage. aimock matches only on that userMessage,
// so on the follow-up it RE-SERVES the same generateSandboxedUi fixture → a
// second OGUI activity → a second pill. It is a replay-only artifact:
// interactively a real LLM replies with prose on the follow-up, so a user sees
// ONE pill, and the canvas renders one surface either way (latest-id
// arbitration). NOT cross-exchange accumulation — each test does a fresh
// page.goto("/").
//
// A terminating follow-up fixture (sequenceIndex 0 = the tool, sequenceIndex 1
// = prose) was attempted to make replay show a single pill, but it destabilized
// this suite: the runtime issues a "Generate a short title for this
// conversation" request whose body EMBEDS the pill text, and aimock matches
// userMessage by substring — so those title-gen requests also match the OGUI
// fixtures and race ahead to consume the sequenceIndex counter, leaving the
// real leg-1 turn to fall through to prose (no tool → no surface renders). So
// we keep the .first() guard; the canvas already renders exactly one surface.
await expect(
page.getByText(/rendered on the canvas/i).first(),
).toBeVisible();
});
}
});
@@ -0,0 +1,120 @@
import { test, expect } from "@playwright/test";
import type { ConsoleMessage, Page } from "@playwright/test";
/**
* CI-safe smoke test for the Northwind banking showcase.
*
* What this covers:
* 1. App boots and the dashboard renders credible content (brand + cards UI).
* 2. The CopilotKit v2 popup launcher opens the modal.
* 3. The arc-leading suggestion pills (incl. the OGUI pill) are visible.
*
* What this intentionally does NOT do:
* - Send any chat message
* - Click any suggestion (which would invoke the agent / hit OpenAI)
* - Exercise any tool
*
* That keeps the test runnable in CI without secrets.
*/
// Console-error filtering: the page may legitimately log network errors for
// the /api/copilotkit endpoint (e.g. if the popup pings it on open and the
// runtime fails because OPENAI_API_KEY is a dummy value), and Next.js dev mode
// prints various non-fatal warnings. We only fail on genuine page/script
// errors that indicate the app itself is broken.
const IGNORED_ERROR_PATTERNS: RegExp[] = [
/favicon/i,
/\/api\/copilotkit/i,
/Failed to load resource/i,
/net::ERR_/i,
/Download the React DevTools/i,
];
function isIgnoredError(message: ConsoleMessage): boolean {
if (message.type() !== "error") return true;
const text = message.text();
return IGNORED_ERROR_PATTERNS.some((re) => re.test(text));
}
function collectConsoleErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error" && !isIgnoredError(msg)) {
errors.push(msg.text());
}
});
return errors;
}
function collectPageErrors(page: Page): string[] {
const errors: string[] = [];
page.on("pageerror", (err) => {
errors.push(err.stack ?? err.message);
});
return errors;
}
test.describe("banking showcase smoke", () => {
test("dashboard renders and popup opens with suggestions", async ({
page,
}) => {
const consoleErrors = collectConsoleErrors(page);
const pageErrors = collectPageErrors(page);
await page.goto("/");
// Brand assertion: the document title is the Northwind Finance brand.
await expect(page).toHaveTitle(/Northwind/);
// The credit-cards page renders an h1 "Credit Cards" and an "Add Card"
// dropdown — that's our credible-content check that the dashboard is up.
await expect(
page.getByRole("heading", { name: "Credit Cards", level: 1 }),
).toBeVisible();
// Open the CopilotKit popup. v2 renders a fixed launcher button with
// data-testid="copilot-chat-toggle" (see CopilotChatToggleButton.tsx).
const launcher = page.getByTestId("copilot-chat-toggle");
await expect(launcher).toBeVisible();
await launcher.click();
// The popup modal exposes the configured assistant title as its aria-label
// (modalHeaderTitle = IDENTITY.assistant = "Northwind Copilot"). We assert
// on the dialog rather than visible text since the title may be rendered
// as aria-only.
await expect(
page.getByRole("dialog", { name: /Northwind Copilot/i }),
).toBeVisible();
// Pills configured by BankingSuggestions render as buttons with
// data-testid="copilot-suggestion" (CopilotChatSuggestionPill.tsx). Assert
// the arc-leading pills are present rather than a brittle exact count —
// the pill catalog grows over time (curated charts, OGUI, cross-page ops).
const suggestions = page.getByTestId("copilot-suggestion");
await expect(suggestions.first()).toBeVisible();
await expect(
suggestions.filter({ hasText: "Approve the $5,000 Google Ads charge" }),
).toBeVisible();
await expect(
suggestions.filter({ hasText: "Review my pending transactions" }),
).toBeVisible();
await expect(
suggestions.filter({ hasText: "Build an interactive spend explorer" }),
).toBeVisible();
// Make sure nothing genuinely broken showed up in the console while the
// page rendered and the popup opened.
expect(
consoleErrors,
`Unexpected console errors:\n${consoleErrors.join("\n")}`,
).toEqual([]);
// Uncaught exceptions don't always surface as console.error — pageerror is
// the canonical "did the app crash?" hook. Any uncaught exception fails
// the smoke test (no filtering).
expect(
pageErrors,
`Unexpected uncaught page errors:\n${pageErrors.join("\n")}`,
).toEqual([]);
});
});
@@ -0,0 +1,20 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
const eslintConfig = [
...nextCoreWebVitals,
...nextTypescript,
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"playwright-report/**",
"test-results/**",
],
},
];
export default eslintConfig;
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// React StrictMode intentionally double-invokes mount effects in DEV ONLY.
// For CopilotKit's AG-UI human-in-the-loop flow that double-mount tears the
// in-flight run down the instant the agent emits a HITL tool call — before
// the approval card can render and return its result — which orphans the tool
// call ("Tool result is missing for tool call …") and then poisons the thread
// so every later message fails. Production builds never double-invoke, so the
// bug is purely a `next dev` artifact; disabling StrictMode makes the live
// HITL chat (and the teach-mode demonstration arc) work when the demo is run
// via `next dev`. No effect on production behavior or correctness.
reactStrictMode: false,
};
export default nextConfig;
+66
View File
@@ -0,0 +1,66 @@
{
"name": "demo-saas-copilot",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"test:e2e": "playwright test",
"test:e2e:ogui": "playwright test -c playwright.ogui.config.ts",
"test:self-learning": "playwright test e2e/memory-learning.spec.ts",
"test:unit": "vitest run",
"mint-dev-license": "node scripts/mint-dev-license.mjs"
},
"dependencies": {
"@copilotkit/a2ui-renderer": "workspace:*",
"@copilotkit/core": "workspace:*",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "workspace:*",
"@copilotkit/shared": "workspace:*",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"hono": "^4.11.4",
"lucide-react": "^0.447.0",
"next": "^16.0.10",
"openai": "^4.96.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.2",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@copilotkit/aimock": "1.19.1",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4",
"@testing-library/react": "^16.1.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9",
"eslint-config-next": "^16.0.10",
"jsdom": "^25.0.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^3.2.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
@@ -0,0 +1,52 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
testIgnore: /ogui-routing\.spec\.ts/,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "list",
use: { baseURL: "http://localhost:3000", trace: "on-first-retry" },
// Two servers: aimock (deterministic LLM) must be up before the dev server so the
// runtime's OPENAI_BASE_URL resolves. The memory-learning E2E additionally needs
// the docker memory stack already running (see README / e2e/memory-learning.spec).
webServer: [
{
// Deterministic LLM for the memory E2E. See e2e/aimock-server.mjs for the
// fixture wiring + the CLI fallback if the programmatic API differs.
command: "node e2e/aimock-server.mjs",
url: `http://localhost:${process.env.AIMOCK_PORT ?? "7099"}/health`,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
{
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
// Existing LLM-free smokes only need *some* OPENAI_API_KEY so the route's
// BuiltInAgent import doesn't crash at boot. The memory E2E additionally
// points the agent at aimock and runs the runtime in Intelligence mode.
OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "test",
OPENAI_BASE_URL:
process.env.OPENAI_BASE_URL ??
`http://localhost:${process.env.AIMOCK_PORT ?? "7099"}/v1`,
INTELLIGENCE_API_URL:
process.env.INTELLIGENCE_API_URL ?? "http://localhost:7050",
INTELLIGENCE_GATEWAY_WS_URL:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:7053",
INTELLIGENCE_API_KEY:
process.env.INTELLIGENCE_API_KEY ??
"cpk_sPRVSEED_seed0privat0longtoken00",
INTELLIGENCE_USER_ID:
process.env.INTELLIGENCE_USER_ID ?? "jordan-beamson",
// Glass Engine must be available for the inspector test to render the
// left-rail toggle (the deployment availability gate; see lib/glass-engine).
GLASS_ENGINE_AVAILABLE: "true",
NEXT_TELEMETRY_DISABLED: "1",
},
},
],
});
@@ -0,0 +1,45 @@
import { defineConfig } from "@playwright/test";
// Dedicated config for the OGUI routing test. Isolated ports (dev :3002, aimock
// :7098) and OSS mode (no INTELLIGENCE_* env) so it never collides with the
// Intelligence-mode suite in playwright.config.ts and needs no docker stack.
export default defineConfig({
testDir: "./e2e",
testMatch: /ogui-routing\.spec\.ts/,
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "list",
use: {
baseURL: "http://localhost:3002",
trace: "on-first-retry",
// Wide enough that the full suggestion-pill row stays on-screen — the pills
// overflow a default 1280px viewport, landing off-screen where clicks fail.
viewport: { width: 1680, height: 900 },
},
webServer: [
{
command:
"AIMOCK_FIXTURES=e2e/fixtures/ogui-routing.fixtures.json AIMOCK_PORT=7098 node e2e/aimock-server.mjs",
url: "http://localhost:7098/health",
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
{
command: "pnpm dev",
url: "http://localhost:3002",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
PORT: "3002",
OPENAI_API_KEY: "test",
OPENAI_BASE_URL: "http://localhost:7098/v1",
// OSS mode: deliberately NO INTELLIGENCE_* vars → the runtime uses the
// InMemoryAgentRunner path (no docker, no gateway).
GLASS_ENGINE_AVAILABLE: "false",
NEXT_TELEMETRY_DISABLED: "1",
},
},
],
});
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# ============================================================================
# run-demo.sh — one-command cold start for the banking demo (self-hosted mode).
#
# cd examples/showcases/banking && ./run-demo.sh
#
# Brings up the memory-enabled Intelligence stack + a working embedder, mints a
# dev license if needed, then starts the Next.js dev server. Idempotent: safe to
# re-run — it reuses anything already up.
#
# Embedder platform split (this is the whole point of the script):
# - Apple Silicon (arm64): the bundled docker `tei` image is amd64-only and
# crash-loops under emulation (Candle backend unavailable -> ONNX/ORT ->
# 404). So we run a NATIVE Metal TEI on the host (:7067) and point app-api
# at it. Same TEI version + model => byte-identical embeddings, ~20x faster.
# - amd64 / Linux / CI: use the bundled docker `tei` via its `cpu-fallback`
# compose profile (native there, no emulation).
#
# Managed Intelligence users: you do NOT need this script — set the
# INTELLIGENCE_* endpoints + a CopilotKit-issued COPILOTKIT_LICENSE_TOKEN in
# .env and run `pnpm dev` directly. This script is the self-hosted local path.
# ============================================================================
set -euo pipefail
DEMO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DEMO_DIR"
LOG_DIR="${TMPDIR:-/tmp}/banking-demo"; mkdir -p "$LOG_DIR"
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
ok() { printf ' \033[1;32m✓\033[0m %s\n' "$*"; }
die() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
wait_http() { # url label maxsecs
local url="$1" label="$2" max="${3:-120}" i=0 code
while [ "$i" -lt "$max" ]; do
code="$(curl -s -m3 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
[ "$code" != "000" ] && { ok "$label ready ($code)"; return 0; }
sleep 3; i=$((i+3))
done
die "$label did not come up at $url within ${max}s (see $LOG_DIR)"
}
# --- Preflight --------------------------------------------------------------
say "Preflight"
docker info >/dev/null 2>&1 || die "Docker is not running. Start Docker Desktop and re-run."
[ -f .env ] || die ".env missing. Copy .env.example to .env and set OPENAI_API_KEY."
grep -q '^OPENAI_API_KEY=.\+' .env || die "OPENAI_API_KEY not set in .env (the agent needs it)."
# The composite image build context + the dev-license signer both need the
# (private) Intelligence source. Default to the sibling checkout the compose uses.
export INTELLIGENCE_REPO="${INTELLIGENCE_REPO:-$(cd "$DEMO_DIR/../../../../Intelligence" 2>/dev/null && pwd || true)}"
[ -n "$INTELLIGENCE_REPO" ] && [ -d "$INTELLIGENCE_REPO" ] \
|| die "INTELLIGENCE_REPO not found. Point it at your Intelligence checkout (self-hosted mode needs the source to build the image + mint a dev license)."
ok "docker running, .env present, INTELLIGENCE_REPO=$INTELLIGENCE_REPO"
# --- Dev license ------------------------------------------------------------
# Self-hosted memory is gated behind a signed offline license. Mint one only if
# .env doesn't already carry a token, so re-runs don't churn the baked key.
if grep -q '^COPILOTKIT_LICENSE_TOKEN=.\+' .env; then
ok "dev license already present in .env"
else
say "Minting a dev license (features.memory=true) into .env"
node scripts/mint-dev-license.mjs --write
fi
# --- Embedder + stack (platform split) --------------------------------------
ARCH="$(uname -m)"
if [ "$ARCH" = "arm64" ] || [ "$ARCH" = "aarch64" ]; then
say "Apple Silicon ($ARCH): native Metal TEI on :7067 (bundled amd64 tei is skipped)"
if [ "$(curl -s -m3 -o /dev/null -w '%{http_code}' http://localhost:7067/health 2>/dev/null)" != "200" ]; then
command -v text-embeddings-router >/dev/null 2>&1 \
|| die "native TEI missing. Install it: brew install text-embeddings-inference"
say " starting native Metal TEI (Qwen/Qwen3-Embedding-0.6B)"
# --max-batch-tokens caps the warmup forward pass. TEI's default (16384) can
# fault the Metal backend during warmup on some Apple Silicon setups: the
# process either deadlocks (all threads parked, 0% CPU) or dies silently
# without a panic — a GPU-level abort — so :7067 never binds and the health
# wait below times out. A small warmup batch clears warmup reliably. It only
# bounds per-request tokens (memory texts are short), not the embedding
# vectors themselves, so recall stays byte-identical.
nohup text-embeddings-router --model-id 'Qwen/Qwen3-Embedding-0.6B' \
--port 7067 --auto-truncate --max-batch-tokens 512 \
> "$LOG_DIR/tei-metal.log" 2>&1 & disown
fi
wait_http "http://localhost:7067/health" "native Metal TEI" 300
# Warm the model so the first real recall isn't a cold forward pass.
curl -s -m30 -o /dev/null -X POST http://localhost:7067/embed \
-H 'Content-Type: application/json' -d '{"inputs":"warmup"}' 2>/dev/null || true
export MEMORY_EMBEDDINGS_URL="http://host.docker.internal:7067"
say "Bringing up the stack (embedder = native host TEI; docker tei stays off)"
docker compose up -d --wait
else
say "amd64/Linux ($ARCH): bundled docker tei via the cpu-fallback profile"
docker compose --profile cpu-fallback up -d --wait
fi
ok "stack healthy: app-api :7050, gateway :7053"
# --- App --------------------------------------------------------------------
say "Starting the Next.js dev server (http://localhost:3000)"
say " (stack is up; Ctrl-C stops only the dev server, not the docker stack)"
exec pnpm dev
@@ -0,0 +1,260 @@
#!/usr/bin/env node
/**
* Real-LLM memory drift smoke — NON-GATING, run manually.
*
* WHY THIS EXISTS
* The deterministic CI gate (e2e/memory-learning.spec.ts) serves the agent's
* LLM from aimock fixtures, so it proves the WIRING (prompt -> tool call -> memory
* backend -> cross-thread recall -> unlock) but is BLIND to behavioral drift: a
* fixture replays a fixed decision, so if a prompt edit makes the real model stop
* calling its memory tools, the aimock test keeps passing. This script closes that
* gap with a REAL OpenAI call.
*
* WHAT IT CHECKS (headless half)
* It seeds the over-limit procedure as a project/operational memory via REST, then
* drives a FRESH-THREAD over-limit approval request through the live runtime and
* asserts the run's event stream contains a `recall_memory` tool call — i.e. the
* live model still RECALLS-FIRST (the autonomous, load-bearing moment). It also
* asserts that NO `save_memory` fires on that over-limit request turn (rule 9:
* GENERAL MEMORY must defer during a procedure). The mid-demonstration turns are
* HITL/headless-unreachable, so that coverage lives in the aimock e2e + the manual
* walkthrough.
*
* WHAT IT DOES NOT CHECK
* The SAVE half (the agent emitting `save_memory` after the teach arc) is gated
* behind the human-in-the-loop teach cards (offerWorkflowRecording ->
* awaitDashboardDemonstration -> saveLearnedWorkflow) and cannot be driven
* headlessly. Verify the save path via the manual UI walkthrough (README step 3)
* and the aimock E2E.
*
* REQUIREMENTS
* - The memory-enabled stack is up (docker compose; see README) and reachable.
* - The demo dev server is running in Intelligence mode (the three INTELLIGENCE_*
* env vars set) with a real OPENAI_API_KEY.
*
* READINESS GATE
* The Intelligence sl-mcp worker can throw an UnhandledPromiseRejection during boot
* and briefly drop /mcp connections even after `docker compose up --wait` reports the
* container healthy. This smoke first polls `POST /mcp initialize` until it returns 200,
* so it only runs once memory is actually serving — a booting/down backend fails fast
* with a clear message instead of masquerading as recall drift.
*
* USAGE
* node scripts/memory-drift-smoke.mjs
* ENV (optional)
* DEMO_URL default http://localhost:3000
* APP_API_URL default http://localhost:7050
* INTELLIGENCE_API_KEY default cpk_sPRVSEED_seed0privat0longtoken00
* CPKI_USER_ID default jordan-beamson
* DRIFT_TXN_ID default t-3 (an over-limit pending seed txn)
*
* NOTE: the run is posted to the AG-UI run endpoint
* `${DEMO_URL}/api/copilotkit/agent/default/run` with a minimal RunAgentInput. If a
* future runtime version changes that path or body shape, that POST is the one spot
* to adjust — the seed/recall REST calls and the stream scan are stable.
*/
const DEMO_URL = process.env.DEMO_URL ?? "http://localhost:3000";
const APP_API_URL = process.env.APP_API_URL ?? "http://localhost:7050";
const KEY =
process.env.INTELLIGENCE_API_KEY ?? "cpk_sPRVSEED_seed0privat0longtoken00";
const USER_ID = process.env.CPKI_USER_ID ?? "jordan-beamson";
const TXN_ID = process.env.DRIFT_TXN_ID ?? "t-3";
const PROCEDURE = (code) =>
`To approve an over-limit charge, open a policy exception with code ${code} ` +
`against the charge and finalize it, then approve the transaction.`;
const SEED_CODE = "EXC-BOARD-APPROVED";
function log(ok, msg) {
console.log(`${ok ? "✓" : "✗"} ${msg}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Poll POST /mcp `initialize` until the sl-mcp worker answers 200 and the SSE body
// completes without a reset. Guards against the Intelligence backend's boot window,
// where the worker throws an UnhandledPromiseRejection and drops /mcp connections even
// though the container reports healthy — running against that window makes the agent
// lose recall_memory mid-run and looks like drift. Fails fast so a booting backend
// never masquerades as a prompt regression.
async function waitForMcpReady({ retries = 30, delayMs = 1000 } = {}) {
const body = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "smoke-preflight", version: "1" },
},
});
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(`${APP_API_URL}/mcp`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"X-Cpki-User-Id": USER_ID,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"mcp-protocol-version": "2025-11-25",
},
body,
});
if (res.ok) {
await res.text(); // reading the body catches a mid-stream reset
return;
}
} catch {
// connection refused / reset during boot — keep polling
}
await sleep(delayMs);
}
throw new Error(
`MCP not ready after ${retries * delayMs}ms — the Intelligence sl-mcp worker never ` +
`stabilized at POST ${APP_API_URL}/mcp (initialize). Bring up / restart the stack ` +
`(docker compose up -d --wait) and confirm 'docker logs' shows no boot-time ` +
`UnhandledPromiseRejection, then retry.`,
);
}
// Confirm the demo dev server (pnpm dev) is up at DEMO_URL. The /mcp gate only covers
// the backend (:7050); the over-limit run below hits the app (:3000). Any HTTP response
// means it is serving; only a connection error means it is down.
async function waitForDemoServer({ retries = 20, delayMs = 1000 } = {}) {
for (let i = 0; i < retries; i++) {
try {
await fetch(DEMO_URL, { method: "GET" });
return;
} catch {
// connection refused — dev server not up yet
}
await sleep(delayMs);
}
throw new Error(
`Demo dev server not reachable at ${DEMO_URL} — start it with 'pnpm dev' ` +
`(Intelligence mode: the three INTELLIGENCE_* env vars + a real OPENAI_API_KEY), then retry.`,
);
}
async function seedProcedureMemory() {
const res = await fetch(`${APP_API_URL}/api/memories`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"X-Cpki-User-Id": USER_ID,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: PROCEDURE(SEED_CODE),
scope: "project",
kind: "operational",
}),
});
if (!res.ok)
throw new Error(
`seed memory failed: HTTP ${res.status} ${await res.text()}`,
);
const body = await res.json().catch(() => ({}));
return body;
}
async function runOverLimitTurn() {
// Minimal AG-UI RunAgentInput. A fresh UUID threadId guarantees no in-thread context
// (the only way the agent can know the procedure is by calling recall_memory) and
// satisfies the Intelligence backend's UUID validation (a custom "drift-..." id 400s).
const threadId = crypto.randomUUID();
const body = {
threadId,
runId: crypto.randomUUID(),
state: {},
// Alex -> jordan-beamson (the id we seed the procedure under). Makes the
// smoke identity-self-sufficient so it passes against the unpinned live demo.
properties: { userId: "9g5h2j1k4l", userRole: "Admin" },
messages: [
{
id: "m1",
role: "user",
content: `Please approve the over-limit charge ${TXN_ID}.`,
},
],
tools: [],
context: [],
forwardedProps: {},
};
const res = await fetch(`${DEMO_URL}/api/copilotkit/agent/default/run`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(
`run POST failed: HTTP ${res.status} ${await res.text().catch(() => "")}\n` +
"hint: confirm the demo is running in Intelligence mode and the run endpoint " +
"path/body shape matches this runtime version (see header NOTE).",
);
}
// Drain the FULL turn's SSE (until the run ends or the deadline) before scanning.
// We must not early-return on the first recall_memory frame: because RECALL FIRST
// makes recall stream before anything else, a spurious general save_memory (rule 9
// leak) streams AFTER it — cancelling the reader on recall would cut that frame off
// and make the negative-save assertion a false negative. The turn ends after the
// agent emits its tool calls (the HITL cards are answered on the NEXT turn, which
// we never send), so draining terminates naturally; the deadline is a backstop.
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
}
reader.cancel().catch(() => {});
return {
recalled: /recall_memory/.test(buf),
sawSave: /save_memory/.test(buf),
};
}
console.log(
`memory drift smoke (REAL LLM) — demo ${DEMO_URL}, app-api ${APP_API_URL}, txn ${TXN_ID}\n`,
);
try {
await waitForMcpReady();
log(true, "preflight: /mcp initialize is serving (memory tools ready)");
await waitForDemoServer();
log(true, `preflight: demo dev server reachable at ${DEMO_URL}`);
const seeded = await seedProcedureMemory();
log(
true,
`seeded project/operational procedure memory (${seeded.absorbed ? "absorbed" : "created"})`,
);
const { recalled, sawSave } = await runOverLimitTurn();
log(
recalled,
recalled
? "PASS: live model emitted recall_memory on a fresh-thread over-limit request"
: "DRIFT: live model did NOT emit recall_memory — the recall-first prompt may have regressed",
);
// Rule 9 (DEFER DURING PROCEDURES): the over-limit request turn must NOT emit a
// general save. A spurious save_memory here means the GENERAL MEMORY block is
// firing inside the teach flow.
log(
!sawSave,
sawSave
? "DRIFT: a save_memory fired on the over-limit request turn — GENERAL MEMORY leaked into the procedure (rule 9)"
: "PASS: no spurious save_memory on the over-limit request turn",
);
process.exit(recalled && !sawSave ? 0 : 1);
} catch (err) {
log(false, `error: ${String(err).slice(0, 400)}`);
process.exit(2);
}
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env node
/**
* Real-LLM general-memory smoke — NON-GATING, run manually.
* Mirrors memory-drift-smoke.mjs. Asserts the GENERAL MEMORY prompt behavior:
* SAVE — a personal fact triggers save_memory(kind:"topical", scope:"user")
* NO-SAVE — a secret is NOT saved
* RECALL — a seeded user-scoped fact is recalled on a fresh thread
* ISOLATION — a user-scoped fact does NOT cross to a different seeded user;
* a project-scoped fact DOES (REST-level, no LLM ranking dependency)
*
* REQUIREMENTS: the memory stack is up and the demo runs in Intelligence mode
* with a real OPENAI_API_KEY (see README). Uses two seeded users:
* ALEX_ID -> jordan-beamson, MAYA_ID -> morgan-fluxx.
*
* READINESS GATE: the Intelligence sl-mcp worker can throw an UnhandledPromiseRejection
* during boot and briefly drop /mcp connections even after `docker compose up --wait`
* reports the container healthy. Running against that window makes the agent lose its
* memory tools mid-run and surfaces as unrelated failures. This smoke first polls
* `POST /mcp initialize` until it returns 200, so it only runs once memory is actually
* serving. If you see connection resets or "no fixture matched" style flakes, it is the
* backend startup window, not this script.
*/
const DEMO_URL = process.env.DEMO_URL ?? "http://localhost:3000";
const APP_API_URL = process.env.APP_API_URL ?? "http://localhost:7050";
const KEY =
process.env.INTELLIGENCE_API_KEY ?? "cpk_sPRVSEED_seed0privat0longtoken00";
const ALEX = {
memberId: "9g5h2j1k4l",
role: "Admin",
userId: "jordan-beamson",
};
const MAYA = { userId: "morgan-fluxx" };
function log(ok, msg) {
console.log(`${ok ? "✓" : "✗"} ${msg}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Poll POST /mcp `initialize` until the sl-mcp worker answers 200 and the SSE body
// completes without a reset. Guards against the Intelligence backend's boot window,
// where the worker throws an UnhandledPromiseRejection and drops /mcp connections
// even though the container reports healthy. Fails fast with a clear message so a
// down/booting backend never masquerades as a feature regression.
async function waitForMcpReady({ retries = 30, delayMs = 1000 } = {}) {
const body = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "smoke-preflight", version: "1" },
},
});
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(`${APP_API_URL}/mcp`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"X-Cpki-User-Id": ALEX.userId,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"mcp-protocol-version": "2025-11-25",
},
body,
});
if (res.ok) {
await res.text();
return;
} // reading the body catches a mid-stream reset
} catch {
// connection refused / reset during boot — keep polling
}
await sleep(delayMs);
}
throw new Error(
`MCP not ready after ${retries * delayMs}ms — the Intelligence sl-mcp worker never ` +
`stabilized at POST ${APP_API_URL}/mcp (initialize). Bring up / restart the stack ` +
`(docker compose up -d --wait) and confirm 'docker logs' shows no boot-time ` +
`UnhandledPromiseRejection, then retry.`,
);
}
async function restSave(userId, content, kind, scope) {
const res = await fetch(`${APP_API_URL}/api/memories`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"X-Cpki-User-Id": userId,
"Content-Type": "application/json",
},
body: JSON.stringify({ content, kind, scope }),
});
if (!res.ok)
throw new Error(`save failed HTTP ${res.status} ${await res.text()}`);
return res.json().catch(() => ({}));
}
async function restRecall(userId, query, scope) {
const res = await fetch(`${APP_API_URL}/api/memories/recall`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"X-Cpki-User-Id": userId,
"Content-Type": "application/json",
},
body: JSON.stringify(scope ? { query, scope } : { query }),
});
if (!res.ok)
throw new Error(`recall failed HTTP ${res.status} ${await res.text()}`);
const body = await res.json().catch(() => ({ memories: [] }));
return body.memories ?? [];
}
// Confirm the demo dev server (pnpm dev) is actually up at DEMO_URL. The /mcp gate
// only covers the backend (:7050); the chat turns below hit the app (:3000). Any HTTP
// response (even 404) means it is serving; only a connection error means it is down.
async function waitForDemoServer({ retries = 20, delayMs = 1000 } = {}) {
for (let i = 0; i < retries; i++) {
try {
await fetch(DEMO_URL, { method: "GET" });
return;
} catch {
// connection refused — dev server not up yet
}
await sleep(delayMs);
}
throw new Error(
`Demo dev server not reachable at ${DEMO_URL} — start it with 'pnpm dev' ` +
`(Intelligence mode: the three INTELLIGENCE_* env vars + a real OPENAI_API_KEY), then retry.`,
);
}
// Drive one chat turn as Alex; return the concatenated SSE buffer so callers can
// scan for tool-call names and, for a personal fact, the kind/scope args.
async function turn(content) {
// A fresh UUID thread per turn: the Intelligence backend validates thread ids as
// UUIDs (a custom "facts-..." id 400s), and a new thread guarantees no in-thread
// context so cross-thread recall is the only way the agent can know a prior fact.
const threadId = crypto.randomUUID();
const res = await fetch(`${DEMO_URL}/api/copilotkit/agent/default/run`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
threadId,
runId: crypto.randomUUID(),
state: {},
properties: { userId: ALEX.memberId, userRole: ALEX.role },
messages: [{ id: "m1", role: "user", content }],
tools: [],
context: [],
forwardedProps: {},
}),
});
if (!res.ok)
throw new Error(
`run failed HTTP ${res.status} ${await res.text().catch(() => "")}`,
);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
}
return buf;
}
console.log(
`memory facts smoke (REAL LLM) — demo ${DEMO_URL}, app-api ${APP_API_URL}\n`,
);
let failures = 0;
const check = (ok, msg) => {
log(ok, msg);
if (!ok) failures++;
};
try {
await waitForMcpReady();
log(true, "preflight: /mcp initialize is serving (memory tools ready)");
await waitForDemoServer();
log(true, `preflight: demo dev server reachable at ${DEMO_URL}`);
// SAVE: a personal fact must trigger save_memory with kind:"topical", scope:"user".
const saveBuf = await turn("remember my favorite food is sushi");
const saved = /save_memory/.test(saveBuf);
const topicalUser =
/"kind"\s*:\s*"topical"/.test(saveBuf) &&
/"scope"\s*:\s*"user"/.test(saveBuf);
check(saved, "SAVE: save_memory fired for a personal fact");
check(
topicalUser,
"SAVE: save carried kind:topical scope:user (cross-thread recallable)",
);
// NO-SAVE: a secret must NOT be saved.
const secretBuf = await turn("remember my API key is sk-abc123");
check(!/save_memory/.test(secretBuf), "NO-SAVE: no save_memory for a secret");
// RECALL cross-thread: seed a user fact via REST, then ask on a fresh thread.
await restSave(
ALEX.userId,
"office is in the Berlin branch",
"topical",
"user",
);
const recallBuf = await turn("where is my office?");
check(
/recall_memory/.test(recallBuf),
"RECALL: recall_memory fired on a fresh thread",
);
// ISOLATION (REST-level, deterministic): seed user + project facts under Alex,
// recall as Maya. Project crosses; user does not.
await restSave(ALEX.userId, "favorite food is sushi", "topical", "user");
await restSave(
ALEX.userId,
"our fiscal year ends in March",
"topical",
"project",
);
const mayaProject = await restRecall(
MAYA.userId,
"fiscal year end",
"project",
);
const mayaUser = await restRecall(MAYA.userId, "favorite food", "user");
check(
mayaProject.some((m) => /march/i.test(m.content)),
"ISOLATION: project fact crosses to the other user",
);
check(
!mayaUser.some((m) => /sushi/i.test(m.content)),
"ISOLATION: user fact does NOT cross to the other user",
);
process.exit(failures === 0 ? 0 : 1);
} catch (err) {
log(false, `error: ${String(err).slice(0, 400)}`);
process.exit(2);
}
@@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* mint-dev-license — DEV-ONLY helper for the SELF-HOSTED Intelligence stack.
*
* Prints (or writes to .env) the two env values that unlock the paid `memory`
* feature on a *locally-built* self-hosted Intelligence stack:
*
* COPILOTKIT_LICENSE_TOKEN=<signed enterprise dev license, features.memory=true>
* BAKED_LICENSE_KEYS_JSON={"<keyId>":"<publicKey>"}
*
* WHY THIS EXISTS (and when you do NOT need it)
* --------------------------------------------
* Self-hosted Intelligence gates memory behind a signed offline license. A
* *locally-built* (unbaked) app-api reads BAKED_LICENSE_KEYS_JSON live at
* runtime, so a throwaway keypair whose public half is baked in can sign a
* license the verifier trusts — no master-key attestation needed. See
* packages/license-verifier/src/keystore.ts in the Intelligence repo.
*
* This does NOT work against — and is NOT needed for — MANAGED Intelligence or
* the official public GHCR images: those bake CopilotKit's master public key as
* the immutable root of trust and ignore a runtime BAKED_LICENSE_KEYS_JSON. For
* the managed path you set a CopilotKit-ISSUED COPILOTKIT_LICENSE_TOKEN and OMIT
* BAKED_LICENSE_KEYS_JSON entirely (see .env.example). This script is purely a
* local-dev convenience and is never imported by the app runtime, so it does not
* couple the demo to the self-hosted stack.
*
* The signer lives in the private Intelligence source (it depends on the
* @cpki/license-catalog workspace package), so this wrapper drives that repo's
* own toolchain rather than vendoring any signing code into this public repo.
* Point INTELLIGENCE_REPO at your Intelligence checkout (defaults to the sibling
* path the docker-compose image build also uses).
*
* USAGE
* node scripts/mint-dev-license.mjs # print the two env lines
* node scripts/mint-dev-license.mjs --write # upsert them into ./.env
* INTELLIGENCE_REPO=/path/to/Intelligence node scripts/mint-dev-license.mjs
* node scripts/mint-dev-license.mjs --org my-org # override the license org
*/
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const DEMO_ROOT = resolve(SCRIPT_DIR, "..");
// Match the docker-compose image build default: ${INTELLIGENCE_REPO:-../../../../Intelligence}
const DEFAULT_INTELLIGENCE_REPO = resolve(
DEMO_ROOT,
"../../../../Intelligence",
);
const INTELLIGENCE_REPO = process.env.INTELLIGENCE_REPO
? resolve(process.env.INTELLIGENCE_REPO)
: DEFAULT_INTELLIGENCE_REPO;
const args = process.argv.slice(2);
const WRITE = args.includes("--write");
const orgFlagIdx = args.indexOf("--org");
const ORG_ID = orgFlagIdx !== -1 ? args[orgFlagIdx + 1] : "casa-de-erlang";
function die(msg) {
console.error(`\n✗ mint-dev-license: ${msg}\n`);
process.exit(1);
}
// --- Preflight: the private signer source + the repo's tsx must be present ---
const signerEntry = resolve(
INTELLIGENCE_REPO,
"libs/license-signing/src/index.ts",
);
if (!existsSync(signerEntry)) {
die(
`Intelligence signer not found at ${signerEntry}.\n` +
` This dev-license path needs the private Intelligence source. Set INTELLIGENCE_REPO\n` +
` to your Intelligence checkout, e.g. INTELLIGENCE_REPO=/path/to/Intelligence\n` +
` NOT needed for MANAGED Intelligence — use a CopilotKit-issued license instead\n` +
` (see .env.example).`,
);
}
const tsxBin = resolve(INTELLIGENCE_REPO, "node_modules/.bin/tsx");
if (!existsSync(tsxBin)) {
die(
`tsx not found at ${tsxBin}. Run 'pnpm install' in the Intelligence repo first.`,
);
}
// --- Mint: run a throwaway signer inside the Intelligence repo context ---
// The signer imports @cpki/license-catalog via that repo's tsconfig paths, so
// the temp file must live inside the repo tree (relative import + repo cwd).
const repoTmpDir = resolve(INTELLIGENCE_REPO, "tmp");
mkdirSync(repoTmpDir, { recursive: true });
const tmpSigner = resolve(
repoTmpDir,
`_mint-banking-license.${process.pid}.ts`,
);
const signerSource = `
import { generateKeyPair, generateKeyId, createLicensePayload, signLicense, getDefaultFeatures } from '../libs/license-signing/src/index.ts';
const kp = generateKeyPair();
const keyId = generateKeyId();
const payload = createLicensePayload(
{ organizationId: ${JSON.stringify(ORG_ID)}, organizationName: 'banking-demo', contactEmail: 'demo@northwind.example',
tier: 'enterprise', planCode: 'enterprise', entitlementSource: 'enterprise_override', issuer: 'banking-demo-local',
seatLimit: 0, features: { ...getDefaultFeatures('enterprise'), memory: true }, removeBranding: true,
expiresAt: new Date('2099-01-01T00:00:00Z'), telemetryId: 'banking-demo-local' },
keyId, 'lic_banking_demo_local',
);
console.log('TOKEN=' + signLicense(payload, kp.privateKey));
console.log('BAKED=' + JSON.stringify({ [keyId]: kp.publicKey }));
`;
let out;
try {
writeFileSync(tmpSigner, signerSource);
out = execFileSync(tsxBin, [tmpSigner], {
cwd: INTELLIGENCE_REPO,
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
});
} catch (err) {
die(
`signing failed inside ${INTELLIGENCE_REPO}. See the error above.\n ${err?.message ?? err}`,
);
} finally {
rmSync(tmpSigner, { force: true });
}
const token = out.match(/^TOKEN=(.+)$/m)?.[1];
const baked = out.match(/^BAKED=(.+)$/m)?.[1];
if (!token || !baked) die(`could not parse signer output:\n${out}`);
const envPairs = {
// main renamed the deployment-mode env and uses the underscore value.
INTELLIGENCE_DEPLOYMENT_MODE: "self_hosted",
COPILOTKIT_LICENSE_TOKEN: token,
BAKED_LICENSE_KEYS_JSON: baked,
};
if (!WRITE) {
console.log(
`# Self-hosted dev license (org: ${ORG_ID}). Paste into .env, or re-run with --write.`,
);
for (const [k, v] of Object.entries(envPairs)) console.log(`${k}=${v}`);
console.log(
`\n# MANAGED Intelligence does NOT use BAKED_LICENSE_KEYS_JSON — see .env.example.`,
);
process.exit(0);
}
// --- --write: upsert the keys into ./.env, preserving everything else ---
const envPath = resolve(DEMO_ROOT, ".env");
let envText = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
for (const [k, v] of Object.entries(envPairs)) {
const line = `${k}=${v}`;
const re = new RegExp(`^${k}=.*$`, "m");
if (re.test(envText)) {
envText = envText.replace(re, line);
} else {
if (envText.length && !envText.endsWith("\n")) envText += "\n";
envText += `${line}\n`;
}
}
writeFileSync(envPath, envText);
console.log(
`✓ Wrote INTELLIGENCE_DEPLOYMENT_MODE, COPILOTKIT_LICENSE_TOKEN, BAKED_LICENSE_KEYS_JSON to ${envPath}`,
);
console.log(
` (org: ${ORG_ID}; self-hosted dev license, features.memory=true)`,
);
@@ -0,0 +1,119 @@
#!/usr/bin/env node
/**
* Over-limit gate smoke test — the premise the self-learning "teach a workflow"
* loop rests on.
*
* The narrated teach loop has the agent recall a saved procedure and apply it to
* a *different* over-limit charge. That only clears the charge if the gate is
* lifted *exclusively* by a finalized exception filed under a JUSTIFYING code
* (see src/lib/store.ts `hasApprovedException` +
* src/app/api/v1/policy-exception-codes.ts `isJustifying`). The Save card echoes
* the demonstrated justifying code (EXC-BOARD-APPROVED) back to the agent as the
* canonical procedure; if a non-justifying code also cleared the gate, or a
* justifying one didn't, that procedure would be wrong. This guards that premise.
*
* Proves, against the running demo server, on one over-limit transaction:
* 1. approve with NO exception → 422 OVER_POLICY_LIMIT (precondition)
* 2. approve after a NON-justifying exception → 422 OVER_POLICY_LIMIT (does not unlock)
* 3. approve after a JUSTIFYING exception → 201 (unlocks)
*
* The in-memory store mutates (step 3 approves the txn), so run this against a
* freshly-started server, and restart the server before a live demo.
*
* Usage:
* node scripts/over-limit-gate-smoke.mjs
* Env (optional):
* DEMO_URL default http://localhost:3000
* GATE_TXN_ID default t-3 (an over-limit pending seed txn NOT used in the demo beats)
*/
const DEMO_URL = process.env.DEMO_URL ?? "http://localhost:3000";
const TXN_ID = process.env.GATE_TXN_ID ?? "t-3";
// EXC-BOARD-APPROVED must stay in sync with the justifying code the Save card's
// CANONICAL_PROCEDURE echoes to the agent (src/app/page.tsx).
const JUSTIFYING_CODE = "EXC-BOARD-APPROVED";
const NON_JUSTIFYING_CODE = "EXC-WILL-REIMBURSE";
const results = [];
function check(name, ok, detail) {
results.push({ name, ok });
console.log(`${ok ? "✓" : "✗"} ${name}${detail ? `${detail}` : ""}`);
return ok;
}
async function approve(id) {
const res = await fetch(`${DEMO_URL}/api/v1/transactions/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "approved" }),
});
const body = await res.json().catch(() => null);
return { status: res.status, body };
}
async function openException(transactionId, code) {
const res = await fetch(`${DEMO_URL}/api/v1/exceptions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ transactionId, code }),
});
const body = await res.json().catch(() => null);
if (!res.ok) throw new Error(`open ${code}: HTTP ${res.status}`);
return body.id;
}
async function finalize(exceptionId) {
const res = await fetch(
`${DEMO_URL}/api/v1/exceptions/${exceptionId}/finalize`,
{ method: "POST", headers: { "Content-Type": "application/json" } },
);
if (!res.ok) throw new Error(`finalize ${exceptionId}: HTTP ${res.status}`);
}
console.log(`over-limit gate smoke — ${DEMO_URL}, txn ${TXN_ID}\n`);
try {
// 1. Precondition: the seed txn is over its policy limit and unapproved.
const pre = await approve(TXN_ID);
check(
"1. approve with no exception is rejected",
pre.status === 422 && pre.body?.error === "OVER_POLICY_LIMIT",
`HTTP ${pre.status} ${pre.body?.error ?? ""}`,
);
// 2. A non-justifying exception files but does NOT lift the gate.
const nonJustId = await openException(TXN_ID, NON_JUSTIFYING_CODE);
await finalize(nonJustId);
const afterNonJust = await approve(TXN_ID);
check(
"2. non-justifying exception does not unlock approval",
afterNonJust.status === 422 &&
afterNonJust.body?.error === "OVER_POLICY_LIMIT",
`HTTP ${afterNonJust.status} ${afterNonJust.body?.error ?? ""}`,
);
// 3. A justifying exception lifts the gate — approval now succeeds.
const justId = await openException(TXN_ID, JUSTIFYING_CODE);
await finalize(justId);
const afterJust = await approve(TXN_ID);
check(
"3. justifying exception unlocks approval",
afterJust.status === 201,
`HTTP ${afterJust.status}`,
);
} catch (error) {
check("gate smoke", false, String(error).slice(0, 200));
}
const failed = results.filter((r) => !r.ok);
console.log(
`\n${failed.length === 0 ? "PASS" : "FAIL"}${results.length - failed.length}/${results.length} checks passed`,
);
if (failed.length > 0) {
console.log(
"hint: run against a FRESHLY-started server (the in-memory store mutates; " +
"a prior run leaves the txn approved). Confirm the seed txn is over-limit.",
);
}
process.exit(failed.length === 0 ? 0 : 1);
@@ -0,0 +1,102 @@
import { describe, it, expect } from "vitest";
import { buildReportOps, SURFACE_ID, type A2UIOp } from "./build-report-ops";
import { CATALOG_ID } from "./catalog/definitions";
type Component = Record<string, unknown>;
type CreateOp = {
version?: string;
createSurface: { surfaceId: string; catalogId: string };
};
type ComponentsOp = { updateComponents: { components: Component[] } };
function createOp(ops: A2UIOp[]): CreateOp | undefined {
return ops.find((op) => "createSurface" in op) as CreateOp | undefined;
}
function componentsOf(ops: A2UIOp[]): Component[] {
const uc = ops.find((op) => "updateComponents" in op) as
| ComponentsOp
| undefined;
return uc?.updateComponents.components ?? [];
}
describe("buildReportOps", () => {
it("emits createSurface (our catalog) + updateComponents with a root Stack", () => {
const ops = buildReportOps({
title: "Spend Report",
kpis: ["totalSpend"],
charts: ["spendingTrend"],
});
const cs = createOp(ops);
expect(cs?.createSurface).toEqual({
surfaceId: SURFACE_ID,
catalogId: CATALOG_ID,
});
expect(cs?.version).toBe("v0.9");
const comps = componentsOf(ops);
const root = comps.find((c) => c.id === "root");
expect(root).toMatchObject({ component: "Stack" });
// root references only defined component ids
const ids = new Set(comps.map((c) => c.id));
for (const childId of root!.children as string[])
expect(ids.has(childId)).toBe(true);
});
it("maps each KPI metric to a StatCard with a human label", () => {
const comps = componentsOf(
buildReportOps({
title: "R",
kpis: ["overLimitCount", "policyCount"],
charts: [],
}),
);
const overLimit = comps.find((c) => c.id === "kpi-overLimitCount");
expect(overLimit).toMatchObject({
component: "StatCard",
metric: "overLimitCount",
label: "Over limit",
});
expect(comps.some((c) => c.component === "Grid")).toBe(true);
});
it("maps each chart kind to a Chart node and includes a filtered Transactions table when asked", () => {
const comps = componentsOf(
buildReportOps({
title: "R",
kpis: [],
charts: ["budgetUsage"],
transactions: "approved",
}),
);
expect(comps.find((c) => c.id === "chart-budgetUsage")).toMatchObject({
component: "Chart",
kind: "budgetUsage",
});
expect(comps.find((c) => c.component === "Transactions")).toMatchObject({
component: "Transactions",
status: "approved",
});
});
it("omits summary when not provided and includes it (muted) when provided", () => {
const without = componentsOf(
buildReportOps({ title: "R", kpis: [], charts: [] }),
);
expect(without.some((c) => c.id === "summary")).toBe(false);
const withSummary = componentsOf(
buildReportOps({
title: "R",
kpis: [],
charts: [],
summary: "Spend overview",
}),
);
expect(withSummary.find((c) => c.id === "summary")).toMatchObject({
component: "Text",
text: "Spend overview",
tone: "muted",
});
});
});
@@ -0,0 +1,179 @@
import { z } from "zod";
import { CATALOG_ID } from "./catalog/definitions";
/**
* Deterministic A2UI op-builder for the banking report canvas.
*
* The agent picks WHAT to show (a small structured selection); this module
* expands that into the verbose A2UI v0.9 operations. Keeping the expansion
* deterministic (rather than having the reasoning model author the full
* component JSON inline) is what keeps generation fast and reliable — the
* model only emits the tiny selection below.
*
* Data is NOT carried in the ops: StatCard/Chart/Transactions bind live client
* data via useReportData() in the catalog renderers. The agent supplies only
* metric/kind selections + label-only text.
*/
/** Must match the middleware's A2UI_OPERATIONS_KEY so tryParseA2UIOperations detects it. */
export const A2UI_OPERATIONS_KEY = "a2ui_operations";
export const SURFACE_ID = "spend-report";
export const REPORT_METRICS = [
"totalSpend",
"pendingCount",
"overLimitCount",
"policyCount",
] as const;
export type ReportMetric = (typeof REPORT_METRICS)[number];
export const REPORT_CHARTS = [
"spendingTrend",
"budgetUsage",
"spendBreakdown",
"incomeVsExpenses",
] as const;
export type ReportChart = (typeof REPORT_CHARTS)[number];
export const REPORT_TX_STATUSES = [
"all",
"pending",
"approved",
"denied",
] as const;
export type ReportTxStatus = (typeof REPORT_TX_STATUSES)[number];
/** Human captions for each KPI — assigned here so the agent needn't supply them. */
const METRIC_LABELS: Record<ReportMetric, string> = {
totalSpend: "Total approved spend",
pendingCount: "Pending approvals",
overLimitCount: "Over limit",
policyCount: "Expense policies",
};
/** Parameters for the render_report tool (kept intentionally small). */
export const renderReportParams = z.object({
title: z
.string()
.describe(
"Short report title, e.g. 'Q2 Spend Report'. LABEL ONLY — no figures, amounts, percentages, or trend claims.",
),
kpis: z
.array(z.enum(REPORT_METRICS))
.describe(
"Which KPI stat cards to show, in order. Pick those relevant to the question.",
),
charts: z
.array(z.enum(REPORT_CHARTS))
.describe("Which charts to show, in order."),
transactions: z
.enum(REPORT_TX_STATUSES)
.optional()
.describe(
"Include a live transactions table filtered by status: 'all', 'pending', 'approved', or 'denied'. Omit to leave it out.",
),
summary: z
.string()
.optional()
.describe(
"Optional one-line NEUTRAL caption under the title. Label-only — no figures, amounts, percentages, or trends.",
),
});
export type RenderReportSpec = z.infer<typeof renderReportParams>;
export type A2UIOp = Record<string, unknown> & { version?: string };
type Component = { id: string; component: string } & Record<string, unknown>;
/**
* Expand a report selection into A2UI v0.9 operations:
* createSurface + updateComponents (flat components, root id "root").
*/
export function buildReportOps(
spec: RenderReportSpec,
surfaceId: string = SURFACE_ID,
): A2UIOp[] {
const components: Component[] = [];
const rootChildren: string[] = [];
components.push({ id: "heading", component: "Heading", text: spec.title });
rootChildren.push("heading");
if (spec.summary) {
components.push({
id: "summary",
component: "Text",
text: spec.summary,
tone: "muted",
});
rootChildren.push("summary");
}
if (spec.kpis.length) {
const kpiIds = spec.kpis.map((metric) => {
const id = `kpi-${metric}`;
components.push({
id,
component: "StatCard",
metric,
label: METRIC_LABELS[metric],
});
return id;
});
components.push({
id: "kpi-grid",
component: "Grid",
columns: Math.min(spec.kpis.length, 4),
children: kpiIds,
});
rootChildren.push("kpi-grid");
}
if (spec.charts.length) {
const chartIds = spec.charts.map((kind) => {
const id = `chart-${kind}`;
components.push({ id, component: "Chart", kind });
return id;
});
components.push({
id: "chart-grid",
component: "Grid",
columns: spec.charts.length >= 2 ? 2 : 1,
children: chartIds,
});
rootChildren.push("chart-grid");
}
if (spec.transactions) {
components.push({
id: "transactions",
component: "Transactions",
status: spec.transactions,
});
rootChildren.push("transactions");
}
components.unshift({
id: "root",
component: "Stack",
gap: "lg",
children: rootChildren,
});
return [
{ version: "v0.9", createSurface: { surfaceId, catalogId: CATALOG_ID } },
{ version: "v0.9", updateComponents: { surfaceId, components } },
];
}
/** Read the surfaceId out of an A2UI operation list (any op kind). */
export function extractSurfaceId(ops: A2UIOp[]): string | null {
for (const op of ops) {
const target = (op.createSurface ??
op.updateComponents ??
op.updateDataModel) as { surfaceId?: string } | undefined;
if (target?.surfaceId) return target.surfaceId;
}
return null;
}
@@ -0,0 +1,100 @@
import { z } from "zod";
export const CATALOG_ID = "https://cpk-a2ui.local/catalogs/banking/v1";
const childRef = z.string();
const childrenRef = z.array(z.string());
const stringOrPath = z.union([z.string(), z.object({ path: z.string() })]);
export const definitions = {
Stack: {
description:
"Vertical layout. Children stack top→bottom. The default page/section container.",
props: z.object({
children: childrenRef,
gap: z.enum(["sm", "md", "lg", "xl"]).optional(),
}),
},
Row: {
description:
"Horizontal layout; wraps on small screens. Use for metric rows or badge groups.",
props: z.object({
children: childrenRef,
gap: z.enum(["sm", "md", "lg"]).optional(),
}),
},
Grid: {
description:
"Responsive grid. Use for a row of StatCards or a pair of charts.",
props: z.object({
children: childrenRef,
columns: z.number().int().min(1).max(4).optional(),
}),
},
Section: {
description:
"Titled section grouping a region of the report (e.g. 'Spend overview').",
props: z.object({ title: z.string(), child: childRef }),
},
Heading: {
description:
"The report title — a LABEL ONLY. Use once at the top. Do NOT embed " +
"figures, amounts, percentages, or trend claims (e.g. NOT 'Spend up 12%') " +
"— all quantitative content comes from StatCard/Chart.",
props: z.object({ text: stringOrPath }),
},
Text: {
description:
"A short NEUTRAL caption or section label (e.g. 'Spend overview', " +
"'This quarter'). Label-only: do NOT state figures, amounts, percentages, " +
"deltas, or trend claims — every quantitative claim must come from " +
"StatCard/Chart/Transactions, which bind live client data. Use " +
"tone='muted' for secondary captions.",
props: z.object({
text: stringOrPath,
tone: z.enum(["default", "muted"]).optional(),
}),
},
StatCard: {
description:
"A single KPI. `metric` selects a live figure computed on the client: " +
"'totalSpend' (sum of approved spend), 'pendingCount' (transactions awaiting " +
"approval), 'overLimitCount' (pending charges over their policy limit), " +
"'policyCount' (number of expense policies). Provide `label` for the caption.",
props: z.object({
metric: z.enum([
"totalSpend",
"pendingCount",
"overLimitCount",
"policyCount",
]),
label: stringOrPath,
}),
},
Chart: {
description:
"A live banking chart. `kind` selects which: 'spendingTrend' (spend over time), " +
"'budgetUsage' (spent vs limit per policy), 'spendBreakdown' (donut of spend by " +
"team/policy), 'incomeVsExpenses' (income vs expenses + net). Data is bound on " +
"the client — do NOT pass numbers.",
props: z.object({
kind: z.enum([
"spendingTrend",
"budgetUsage",
"spendBreakdown",
"incomeVsExpenses",
]),
}),
},
Transactions: {
description:
"A live table of transactions. `status` filters which rows show: 'all', " +
"'pending' (awaiting approval), 'approved', or 'denied' (defaults to " +
"'all'). Data is bound on the client — do NOT pass numbers or rows.",
props: z.object({
status: z.enum(["all", "pending", "approved", "denied"]).optional(),
}),
},
};
export type Definitions = typeof definitions;
@@ -0,0 +1,12 @@
import { createCatalog, extractSchema } from "@copilotkit/a2ui-renderer";
import { CATALOG_ID, definitions } from "./definitions";
import { renderers } from "./renderers";
export const catalog = createCatalog(definitions, renderers, {
catalogId: CATALOG_ID,
includeBasicCatalog: false,
});
export const catalogSchema = extractSchema(definitions);
export { CATALOG_ID, definitions };
@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import type { RendererProps } from "@copilotkit/a2ui-renderer";
import {
ExpenseRole,
type ExpensePolicy,
type Transaction,
} from "@/app/api/v1/data";
import { formatCurrency } from "@/lib/utils";
import { ReportDataProvider, type ReportData } from "../report-data";
import { renderers } from "./renderers";
// Typed fixture builders keep the test honest against the real domain shapes
// (no `as never`): the renderer derives "over limit" from policies, so the
// fixture must genuinely exercise that derivation.
function makeTransaction(overrides: Partial<Transaction>): Transaction {
return {
id: "t",
title: "Charge",
amount: -100,
date: "2026-01-01",
policyId: "p1",
cardId: "c1",
status: "approved",
...overrides,
};
}
function makePolicy(overrides: Partial<ExpensePolicy>): ExpensePolicy {
return {
id: "p1",
type: ExpenseRole.Engineering,
limit: 1000,
spent: 0,
...overrides,
};
}
// Policy p1: limit 1000, spent 900.
// - t1 approved (-300) -> counts toward totalSpend
// - t2 pending (-200) 900+200=1100 > 1000 -> OVER LIMIT (no exception)
// - t3 pending (-50) 900+50 =950 <=1000 -> pending, not over limit
// - t4 pending (-500) 900+500=1400>1000 -> over threshold BUT has an
// active exception -> NOT over limit
const data: ReportData = {
policies: [makePolicy({ id: "p1", limit: 1000, spent: 900 })],
transactions: [
makeTransaction({ id: "t1", status: "approved", amount: -300 }),
makeTransaction({ id: "t2", status: "pending", amount: -200 }),
makeTransaction({ id: "t3", status: "pending", amount: -50 }),
makeTransaction({
id: "t4",
status: "pending",
amount: -500,
activeExceptionId: "ex1",
}),
],
};
// The catalog renderers are typed as A2UI RendererProps components; call them
// directly as functions at the test boundary with the props they consume.
const StatCard = renderers.StatCard as (
props: RendererProps<{ metric: string; label: string }>,
) => React.ReactElement;
function renderStatCard(metric: string, label: string) {
return render(
<ReportDataProvider value={data}>
<StatCard
props={{ metric, label }}
// `children` here is the RendererProps render-callback (a function),
// not React children — passing it as a prop is intentional.
// eslint-disable-next-line react/no-children-prop
children={() => null as unknown as React.ReactNode}
/>
</ReportDataProvider>,
);
}
describe("banking catalog StatCard renderer", () => {
it("overLimitCount derives over-limit pending charges from policies", () => {
// Only t2 pushes its policy over the limit with no clearing exception.
renderStatCard("overLimitCount", "Over limit");
expect(screen.getByText("Over limit")).toBeDefined();
expect(screen.getByText("1")).toBeDefined();
});
it("pendingCount counts all pending transactions", () => {
// t2, t3, t4 are pending.
renderStatCard("pendingCount", "Pending");
expect(screen.getByText("Pending")).toBeDefined();
expect(screen.getByText("3")).toBeDefined();
});
it("totalSpend sums approved charges and formats as currency", () => {
// Only t1 (approved, -300) contributes.
renderStatCard("totalSpend", "Total spend");
expect(screen.getByText(formatCurrency(300))).toBeDefined();
});
});
const Transactions = renderers.Transactions as (
props: RendererProps<{ status?: string }>,
) => React.ReactElement;
function renderTransactions(status: string) {
return render(
<ReportDataProvider value={data}>
<Transactions
props={{ status }}
// `children` is the RendererProps render-callback, not React children.
// eslint-disable-next-line react/no-children-prop
children={() => null as unknown as React.ReactNode}
/>
</ReportDataProvider>,
);
}
describe("banking catalog Transactions renderer", () => {
it("shows an empty state for a status with no matching rows", () => {
// The fixture has no denied transactions.
renderTransactions("denied");
expect(screen.getByText("No denied transactions.")).toBeDefined();
});
it("renders the table (not the empty state) when rows match the status", () => {
// t1 is approved, so the table renders instead of the empty state.
renderTransactions("approved");
expect(screen.queryByText("No approved transactions.")).toBeNull();
});
});
@@ -0,0 +1,194 @@
"use client";
import type { RendererProps } from "@copilotkit/a2ui-renderer";
import {
SpendingTrendChart,
BudgetUsageChart,
SpendBreakdownChart,
IncomeExpenseChart,
} from "@/components/analytics-charts";
import { TransactionsList } from "@/components/transactions-list";
import { isOverLimit } from "@/lib/over-limit";
import { cn, formatCurrency } from "@/lib/utils";
import { useReportData } from "../report-data";
const GAP = { sm: "gap-2", md: "gap-4", lg: "gap-6", xl: "gap-10" } as const;
// Text props in the catalog are `string | { path }` (a data-bound ref). The
// A2UI runtime resolves refs before render, but the Zod-inferred type still
// carries the union, so coerce to a display string here.
type TextRef = string | { path: string };
const asText = (value: TextRef): string =>
typeof value === "string" ? value : "";
function Slot({ render }: { render: React.ReactNode }) {
return <>{render}</>;
}
const Stack = ({
props,
children,
}: RendererProps<{ children: string[]; gap?: keyof typeof GAP }>) => (
<div className={cn("flex flex-col", GAP[props.gap ?? "md"])}>
{props.children?.map((id) => (
<Slot key={id} render={children(id)} />
))}
</div>
);
const Row = ({
props,
children,
}: RendererProps<{ children: string[]; gap?: "sm" | "md" | "lg" }>) => (
<div className={cn("flex flex-wrap", GAP[props.gap ?? "md"])}>
{props.children?.map((id) => (
<Slot key={id} render={children(id)} />
))}
</div>
);
const Grid = ({
props,
children,
}: RendererProps<{ children: string[]; columns?: number }>) => (
<div
className="grid gap-4"
style={{
gridTemplateColumns: `repeat(${props.columns ?? 3}, minmax(0, 1fr))`,
}}
>
{props.children?.map((id) => (
<Slot key={id} render={children(id)} />
))}
</div>
);
const Section = ({
props,
children,
}: RendererProps<{ title: string; child: string }>) => (
<section className="space-y-3">
<h2 className="text-lg font-semibold text-ink">{props.title}</h2>
<Slot render={children(props.child)} />
</section>
);
const Heading = ({ props }: RendererProps<{ text: TextRef }>) => (
<h1 className="text-2xl font-semibold tracking-tight text-ink">
{asText(props.text)}
</h1>
);
const Text = ({
props,
}: RendererProps<{ text: TextRef; tone?: "default" | "muted" }>) => (
<p
className={cn(
"text-sm",
props.tone === "muted" ? "text-ink-muted" : "text-ink",
)}
>
{asText(props.text)}
</p>
);
function CardShell({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 shadow-soft">
<div className="text-xs uppercase tracking-wide text-ink-muted">
{label}
</div>
<div className="mt-1 text-2xl font-semibold text-ink">{value}</div>
</div>
);
}
const StatCard = ({
props,
}: RendererProps<{
metric: "totalSpend" | "pendingCount" | "overLimitCount" | "policyCount";
label: TextRef;
}>) => {
const { transactions, policies } = useReportData();
const pending = transactions.filter((t) => t.status === "pending");
let value = "";
switch (props.metric) {
case "totalSpend":
value = formatCurrency(
transactions
.filter((t) => t.status === "approved")
.reduce((sum, t) => sum + Math.abs(t.amount), 0),
);
break;
case "pendingCount":
value = String(pending.length);
break;
case "overLimitCount":
value = String(pending.filter((t) => isOverLimit(t, policies)).length);
break;
case "policyCount":
value = String(policies.length);
break;
}
return <CardShell label={asText(props.label)} value={value} />;
};
const Chart = ({
props,
}: RendererProps<{
kind: "spendingTrend" | "budgetUsage" | "spendBreakdown" | "incomeVsExpenses";
}>) => {
const { transactions, policies } = useReportData();
const inner = (() => {
switch (props.kind) {
case "spendingTrend":
return <SpendingTrendChart transactions={transactions} />;
case "budgetUsage":
return <BudgetUsageChart policies={policies} />;
case "spendBreakdown":
return <SpendBreakdownChart policies={policies} />;
case "incomeVsExpenses":
return <IncomeExpenseChart transactions={transactions} />;
}
})();
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 shadow-soft">
{inner}
</div>
);
};
const Transactions = ({
props,
}: RendererProps<{ status?: "all" | "pending" | "approved" | "denied" }>) => {
const { transactions, policies } = useReportData();
const status = props.status ?? "all";
const rows =
status === "all"
? transactions
: transactions.filter((t) => t.status === status);
if (!rows.length) {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted">
{status === "all" ? "No transactions." : `No ${status} transactions.`}
</div>
);
}
return (
<div className="rounded-2xl border border-hairline bg-surface p-4">
<TransactionsList transactions={rows} policies={policies} />
</div>
);
};
export const renderers = {
Stack,
Row,
Grid,
Section,
Heading,
Text,
StatCard,
Chart,
Transactions,
};
@@ -0,0 +1,31 @@
"use client";
import { createContext, useContext } from "react";
import type { ExpensePolicy, Transaction } from "@/app/api/v1/data";
export interface ReportData {
transactions: Transaction[];
policies: ExpensePolicy[];
}
const ReportDataContext = createContext<ReportData | null>(null);
export function ReportDataProvider({
value,
children,
}: {
value: ReportData;
children: React.ReactNode;
}) {
return (
<ReportDataContext.Provider value={value}>
{children}
</ReportDataContext.Provider>
);
}
/** Live banking data for A2UI report renderers. Returns empty arrays if a
* renderer is mounted outside the provider (shouldn't happen in the canvas). */
export function useReportData(): ReportData {
return useContext(ReportDataContext) ?? { transactions: [], policies: [] };
}
@@ -0,0 +1,313 @@
"use client";
import type {
NewCardRequest,
Card as ICard,
ExpensePolicy,
Transaction,
PolicyException,
} from "@/app/api/v1/data";
import { MemberRole } from "@/app/api/v1/data";
import { randomDigits } from "@/lib/utils";
import { useEffect, useState } from "react";
import { useAuthContext } from "@/components/auth-context";
// Cross-instance revalidation bus.
//
// `useCreditCards()` keeps its OWN local state and is called independently by
// several components (the dashboard page, copilot-context where the chat's
// approveTransaction/finalize tools live, page.tsx, …). A mutation made through
// one instance therefore would NOT refresh another — e.g. the agent approving a
// charge in chat (copilot-context's instance) left the dashboard's pending
// table showing the charge as still pending. Each instance registers a
// refetch callback here; every mutation calls `notifyDataChanged()` so ALL live
// instances re-pull from the server and every view reflects the change at once.
const dataChangeListeners = new Set<() => void>();
function notifyDataChanged() {
for (const listener of dataChangeListeners) listener();
}
export default function useCreditCards() {
const [cards, setCards] = useState<ICard[]>([]);
const [policies, setPolicies] = useState<ExpensePolicy[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const { currentUser } = useAuthContext();
const changePin = async ({
cardId,
pin,
}: {
cardId: string;
pin: string;
}) => {
try {
const response = await fetch(`/api/v1/cards/${cardId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ pin }),
});
if (!response.ok) {
throw new Error("Failed to change PIN");
}
return response.json();
} catch (error) {
console.error("Error changing PIN:", error);
}
};
const fetchCards = async () => {
try {
const response = await fetch("/api/v1/cards");
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
setCards(data);
} catch (error) {
console.error("There was a problem with the fetch operation:", error);
}
};
const fetchPolicies = async () => {
try {
const response = await fetch("/api/v1/policies");
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
setPolicies(data);
} catch (error) {
console.error("There was a problem with the fetch operation:", error);
}
};
const fetchTransactions = async () => {
try {
const response = await fetch("/api/v1/transactions");
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
setTransactions(data);
} catch (error) {
console.error("There was a problem with the fetch operation:", error);
}
};
useEffect(() => {
const refetchAll = () => {
void Promise.all([fetchCards(), fetchPolicies(), fetchTransactions()]);
};
refetchAll();
// Refresh this instance whenever ANY instance reports a mutation, so the
// dashboard reflects chat-driven approvals (and vice-versa) immediately.
dataChangeListeners.add(refetchAll);
return () => {
dataChangeListeners.delete(refetchAll);
};
}, []);
const addNewCard = async ({ type, color, pin }: NewCardRequest) => {
const reqBody = {
// random 4 digits
last4: randomDigits(4).toString(),
// 5 years from now in format MM/YY
expiry:
new Date(new Date().setFullYear(new Date().getFullYear() + 5))
.toISOString()
.split("-")[1] +
"/" +
new Date().toISOString().split("-")[0].slice(2),
type: type,
color: color,
pin: pin,
};
try {
const response = await fetch("/api/v1/cards", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reqBody),
});
if (!response.ok) {
throw new Error("Failed to add new card");
}
notifyDataChanged();
return response.json();
} catch (error) {
console.error("Error adding new card:", error);
}
};
const assignPolicyToCard = async ({
policyId,
cardId,
}: {
policyId: string;
cardId: string;
}) => {
try {
const response = await fetch(`/api/v1/cards/${cardId}/policy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ policyId }),
});
if (!response.ok) {
throw new Error("Failed to assign policy");
}
notifyDataChanged();
return response.json();
} catch (error) {
console.error("Error assigning policy:", error);
}
};
const addNoteToTransaction = async ({
transactionId,
content,
}: {
transactionId: string;
content: string;
}) => {
const reqBody = {
content,
userId: currentUser.id,
};
try {
const response = await fetch(`/api/v1/transactions/${transactionId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reqBody),
});
if (!response.ok) {
throw new Error("Failed to add new card");
}
notifyDataChanged();
return response.json();
} catch (error) {
console.error("Error adding new card:", error);
}
};
const changeTransactionStatus = async ({
id,
status,
}: {
id: string;
status: "pending" | "approved" | "denied";
}): Promise<{ ok: boolean; error?: string }> => {
try {
const response = await fetch(`/api/v1/transactions/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ status }),
});
// Always refresh from the server so the UI reflects the real state
// whether the write succeeded or was rejected (e.g. the over-limit gate).
notifyDataChanged();
if (!response.ok) {
// Surface the server's symptom-only message (e.g. "<team> policy limit
// exceeded") so the agent + UI can learn the failure instead of
// silently reporting a false success.
const body = await response.json().catch(() => null);
return {
ok: false,
error: body?.message ?? "Failed to change transaction status",
};
}
return { ok: true };
} catch (error) {
console.error("Error changing transaction status:", error);
return { ok: false, error: "Network error" };
}
};
const openPolicyException = async ({
transactionId,
code,
}: {
transactionId: string;
code: string;
}): Promise<{ ok: boolean; data?: PolicyException; error?: string }> => {
try {
const response = await fetch("/api/v1/exceptions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ transactionId, code }),
});
const body = await response.json().catch(() => null);
notifyDataChanged();
if (!response.ok) {
return {
ok: false,
error: body?.message ?? "Failed to open policy exception",
};
}
return { ok: true, data: body as PolicyException };
} catch (error) {
console.error("Error opening policy exception:", error);
return { ok: false, error: "Network error" };
}
};
const finalizePolicyException = async ({
exceptionId,
}: {
exceptionId: string;
}): Promise<{ ok: boolean; data?: PolicyException; error?: string }> => {
try {
const response = await fetch(
`/api/v1/exceptions/${exceptionId}/finalize`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
const body = await response.json().catch(() => null);
notifyDataChanged();
if (!response.ok) {
return {
ok: false,
error: body?.message ?? "Failed to finalize policy exception",
};
}
return { ok: true, data: body as PolicyException };
} catch (error) {
console.error("Error finalizing policy exception:", error);
return { ok: false, error: "Network error" };
}
};
return {
cards:
currentUser.role === MemberRole.Admin
? cards
: cards.filter((card) => {
const policy = policies.find(
(policy) => policy.id === card.expensePolicyId,
);
return policy?.type === currentUser.team;
}),
policies,
transactions,
changePin,
addNewCard,
addNoteToTransaction,
assignPolicyToCard,
changeTransactionStatus,
openPolicyException,
finalizePolicyException,
};
}
@@ -0,0 +1,359 @@
import {
CopilotRuntime,
createCopilotHonoHandler,
InMemoryAgentRunner,
BuiltInAgent,
CopilotKitIntelligence,
defineTool,
} from "@copilotkit/runtime/v2";
import type { IdentifyUserCallback } from "@copilotkit/runtime/v2";
import { handle } from "hono/vercel";
import { resolveUserId, resolveUserName } from "@/lib/intelligence/user-id";
import {
renderReportParams,
buildReportOps,
A2UI_OPERATIONS_KEY,
SURFACE_ID,
} from "@/a2ui/build-report-ops";
/**
* Backend tool: render a spend report on the canvas. The agent supplies only a
* small selection (title + which KPIs/charts); this handler deterministically
* expands it into A2UI operations and returns them wrapped in
* `a2ui_operations`, which the A2UI middleware detects (injectA2UITool:false)
* and turns into an `a2ui-surface` activity the ReportCanvas renders. Running
* server-side keeps the emission in the same run, and building the ops in code
* (rather than having the reasoning model author the full component JSON
* inline) is what keeps it fast and reliable.
*/
const renderReportTool = defineTool({
name: "render_report",
description:
"Render a multi-widget spend report on the CANVAS (the app's main content " +
"area, outside the chat). Choose which KPIs and charts to include; the " +
"client renders live banking figures — you never pass numbers. Use for a " +
"report/overview/dashboard/analysis request or 'show it on the canvas', " +
"NOT for a single inline chart.",
parameters: renderReportParams,
execute: async (spec) => ({
// Unique surfaceId per report so dismissing one report never suppresses a
// later one (the canvas tracks the dismissed surfaceId).
[A2UI_OPERATIONS_KEY]: buildReportOps(
spec,
`${SURFACE_ID}-${Date.now().toString(36)}`,
),
}),
});
const bankingAgent = new BuiltInAgent({
// Full gpt-5.4 (not -mini): the teach-flow's multi-step tool routing
// (recall_memory → offerWorkflowRecording → awaitDashboardDemonstration →
// saveLearnedWorkflow) is more reliable on the non-mini model. `openai/gpt-5.4`
// is the alias used across the repo.
model: "openai/gpt-5.4",
prompt: `You are the Northwind Copilot, an assistant embedded in a corporate
banking dashboard. You help users view transactions, manage credit cards,
assign expense policies, and navigate the app. Use the provided tools. Respect
the user's role: if a tool is unavailable to the current user, explain that
they lack permission rather than attempting it.
When you call the showTransactions tool, the rendered list is the single
source of truth for the user. Do NOT restate transaction counts, totals,
or per-row details in prose — the list already shows them. Keep any
accompanying message to at most one short sentence (e.g. "Here are your
recent transactions.") and let the rendered list speak for itself.
When the user asks what is pending, what needs approval, or to review the
approval queue, call showPendingApprovals — it renders the interactive queue in
the chat. Do not list pending charges in prose. But when the user asks you to
APPROVE or CLEAR one specific charge, do NOT call showPendingApprovals — follow
the over-limit handling rule below (recall first, then offer to record).
You can also visualize data directly in the chat. Prefer rendering the chart or
diagram over describing the numbers in prose:
- showSpendingTrend — spending over time / trend / history questions.
- showBudgetUsage — budget, limit, or utilization questions ("how's our budget?").
- showSpendBreakdown — "where is the money going?" / spend-by-team breakdowns.
- showIncomeVsExpenses — income vs expenses / cash-flow / net-position questions.
- showApprovalFlow — ONLY when the user asks how clearing an over-limit charge works (a static explainer). Never in response to a request to approve or clear a charge.
Tools available to you:
- showTransactions — show a filtered list of transactions in the chat.
- showPendingApprovals — show the interactive queue of pending transactions. Call when the user asks what is pending or to review approvals — NOT as the response when they ask you to approve one specific charge.
- showSpendingTrend — chart of spending over time.
- showBudgetUsage — chart of budget usage (spent vs limit) per policy.
- showSpendBreakdown — donut chart of spend by team/policy.
- showIncomeVsExpenses — chart comparing income vs expenses.
- showApprovalFlow — a static explainer diagram of the clearing process. Call ONLY when the user explicitly asks how clearing an over-limit charge works (e.g. "how does this work?"). NEVER call it when the user asks you to approve or clear a specific charge — that path is recall_memory → offerWorkflowRecording.
- addNewCard — request a new expense card. Requires human approval.
- setCardPin — change the PIN on an existing card. Requires human approval.
- assignPolicyToCard — assign an expense policy to a card. Requires human approval.
- selectCard — render a visual card picker (brand + last 4 digits) for the user to choose a card. Requires human selection.
- addNoteToTransaction — attach a note to a transaction. Requires human approval.
- approveTransaction — approve a single transaction. Only valid once a charge can actually be approved (within its limit, or its over-limit gate already lifted). Requires human approval.
- openPolicyException — open a draft policy exception against a transaction. Requires human approval.
- finalizePolicyException — finalize a policy exception. Requires human approval.
- sendSpendAlert — send a spend alert notification for a card.
- requestCardReplacement — request a replacement card for an existing card.
- flagForReview — flag a transaction for manual review.
- offerWorkflowRecording — offer to record how the user handles a situation you have no saved procedure for. Requires human approval.
- awaitDashboardDemonstration — wait while the user demonstrates the fix on the dashboard so you can learn it. Requires human approval.
- saveLearnedWorkflow — summarize the demonstrated procedure and ask the user to save it. Requires human approval.
- recall_memory — search durable long-term memory for a saved procedure, fact, or preference. See the memory rules below for when to call it.
- save_memory — persist a durable procedure, fact, or preference. Choose kind and scope per the memory rules below; do NOT hardcode operational/project.
When you need the user to choose which card to act on (for example before
assigning a policy or changing a PIN), call selectCard to render a visual card
picker rather than listing the cards as text. Wait for the user's selection,
then continue with the chosen card.
ACTION DISCIPLINE: Only invoke a write tool when the user has explicitly asked
for that specific action. Do not chain or substitute actions on your own
initiative. If you do not have a known procedure that covers what is being
asked, do NOT improvise a substitute action or guess at parameter values.
When the user asks you to approve a charge that is over its policy limit
(overLimit: true in the transactions context) and you do NOT already hold a
saved procedure for over-limit charges: do NOT call approveTransaction,
showApprovalFlow, showPendingApprovals, or open any approval card — none of
those approve the charge, and the explainer/queue cards are NOT a substitute
for offering to learn the procedure. Instead, in the SAME turn: (1) briefly say you do not have a
saved way to approve an over-limit charge yet, and (2) IMMEDIATELY call
offerWorkflowRecording with that charge's id to offer to learn how the user
handles it. Never stop after only explaining — always make that offer in the
same turn (see TEACH & RECALL). For any other failure you have no procedure for,
report exactly what you tried and why it failed, then ask the user how they
would like to proceed.
TEACH & RECALL (durable self-learning via long-term memory):
You have long-term memory tools: recall_memory, save_memory. They persist across
threads and across users on this team (project scope).
RECALL FIRST. Whenever the user asks you to approve an over-limit charge
(overLimit: true), BEFORE doing anything else call
recall_memory({ query: "how to approve an over-limit charge / policy exception procedure" }).
- If recall returns a procedure, APPLY IT step by step (file the policy exception
with the specified code, then approveTransaction). Do NOT offer to record and do
NOT guess a code — use only the code the recalled procedure specifies.
- If recall returns nothing, you have no saved procedure: say so briefly and, in the
SAME turn, call offerWorkflowRecording with that charge's id.
LEARN BY WATCHING. If offerWorkflowRecording returns "started", call
awaitDashboardDemonstration with the same transaction id and watch — do not direct
the user. It reports back the exception code they used.
SAVE THE PROCEDURE. After awaitDashboardDemonstration reports a filed exception,
call saveLearnedWorkflow with that transaction id and the exact code to ask the
user to save it. Once saveLearnedWorkflow returns a result whose status is "saved",
call save_memory with:
scope: "project",
kind: "operational",
content: "To approve an over-limit charge, open a policy exception with code <CODE>
against the charge and finalize it, then approve the transaction."
(substitute the exact demonstrated code from the saveLearnedWorkflow result). Save
this procedure AT MOST ONCE. If save_memory returns status "near_duplicates" or
"absorbed", the procedure is already stored — do not save again; just continue.
The charge the user demonstrated on is already cleared by that demonstration — do not
re-approve it. Apply the saved procedure only to OTHER over-limit charges afterwards.
GENERAL MEMORY (durable facts & preferences — separate from the over-limit procedure):
Beyond the over-limit procedure above, you can remember arbitrary facts and
preferences with the same recall_memory / save_memory tools.
1. RECALL FIRST (general). Before answering anything that could depend on who this
person is or how they like things done — and on a fresh thread's first relevant
turn — call recall_memory with a short query. A new thread has no visible
history, so rely on recall, not the chat log.
2. SAVE DURABLE FACTS — REQUIRED. When the user asks you to remember something
("remember that…", "note that…", "keep in mind…", "fyi…") OR states a durable
personal fact/preference/constraint/role/schedule, call save_memory in the SAME
turn, before replying. Acknowledging in prose ("Got it, I'll remember…") WITHOUT
calling save_memory is a FAILURE — nothing is stored and the fact is lost on the
next thread.
3. SAVE ≠ RECALL. Recalling to check for a duplicate does not satisfy the save;
when the user gives a new fact, emit BOTH calls in the same turn.
4. CLASSIFY. kind: "topical" for a stable fact/preference ("favorite food is
sushi", "prefers spend reports by team"); "episodic" for a dated one-off; the
over-limit procedure uses "operational" (handled by TEACH & RECALL, not here).
scope: "user" for personal facts (the default for "about me"); "project" for
team-shared facts.
5. ASK WHEN AMBIGUOUS. If a fact is genuinely dual-use (could be personal or
team-wide), ask one short question — "Just for you, or the whole team?" — before
saving. Otherwise infer per (4).
6. SAVE ONCE / DEDUP. Save each fact at most once per turn. OMIT the "supersedes"
parameter entirely on a normal save — only include it when the user is
correcting a specific earlier fact AND you have that memory's exact id from a
recall_memory result. "supersedes" must be a real memory UUID; never pass an
empty string, a placeholder, the content, or a guessed value (the tool rejects
a non-UUID and the save fails). On a "near_duplicates" status: if it's already
known, just continue; if the user is correcting it, re-save once with
"supersedes" set to the recalled memory's id. On "absorbed": continue. Never
re-issue the same save.
7. SECRETS EXCLUSION. NEVER store passwords, API keys, tokens, or full card/SSN
numbers, even on an explicit "remember". Ordinary facts (office, schedule,
dietary preference, report preferences) ARE saved.
8. VOICE. Speak about memories like a person ("earlier you mentioned…"); never
name the tools or memory ids to the user.
9. DEFER DURING PROCEDURES. While an over-limit approval / teach-flow is in
progress (from the first recall_memory for the over-limit procedure through the
saveLearnedWorkflow save), TEACH & RECALL owns ALL memory calls. Suspend this
GENERAL MEMORY save rule for the duration: do NOT save_memory facts/roles the
user states while demonstrating (e.g. "we file travel overages under TRAVEL-01",
"I'm the finance manager"), and do NOT emit an "I'll remember that" line
mid-procedure. The only save during the procedure is the operational one. Resume
general save/recall once the procedure completes.
You can render a full multi-widget report on the CANVAS (the app's main content
area, outside the chat). Pick by intent:
- REPORT / ANALYSIS / OVERVIEW / DASHBOARD, or "show it on the canvas" -> call render_report. Choose which KPIs (kpis) and charts to include, and set transactions to a status when a transactions table is relevant. The canvas binds live figures on the client — you only pick which widgets to show and a label-only title/summary.
- A SINGLE named chart or metric -> use the existing in-chat chart tool instead (renders inline in the conversation). Do NOT open the canvas for these.
Examples:
- "build me a spend report" / "give me an overview of our spending" / "show it on the canvas" -> render_report (canvas).
- "show the spending trend" / "what's our budget usage?" -> in-chat chart tool (inline).
render_report inputs: kpis is any of totalSpend | pendingCount | overLimitCount | policyCount; charts is any of spendingTrend | budgetUsage | spendBreakdown | incomeVsExpenses; transactions (optional) is one of all | pending | approved | denied. title and summary are LABELS ONLY — never put figures, amounts, percentages, or trend claims in them; every number comes from the selected KPIs/charts, which bind live data on the client.
OPEN GENERATIVE UI (generateSandboxedUi): You can also author a custom, sandboxed
interactive UI on demand with the built-in generateSandboxedUi tool. Use it ONLY
for something the standard charts and the render_report canvas cannot express: an
interactive tool, calculator, explorer, what-if/scenario simulator, playground,
prototype, or a custom/novel visualization (e.g. a treemap, heatmap, sankey, 3D
view, or a specific chart library like Chart.js / D3 / Three.js).
- NOT for a report, overview, dashboard, or a standard chart — those ALWAYS use
render_report or the in-chat chart tools, EVEN WHEN the user says "build" or
"make" (e.g. "build a spend report on the canvas" -> render_report, never
generateSandboxedUi).
- When you build such a UI you MUST obtain every figure by calling the exposed
sandbox functions (getTransactions, getPolicies, getCards, getKpis) from inside
the generated JavaScript. NEVER invent, inline, or hardcode numbers.
- generateSandboxedUi is NEVER part of the over-limit approval / teach-recall arc,
the approvals queue, or the standard chart/report responses.`,
tools: [renderReportTool],
// Temperature 0 for consistent tool routing — the teach-flow sequencing
// (recall_memory → offerWorkflowRecording on an over-limit approve) needs the
// agent to pick the same path every time, not sample alternatives.
temperature: 0,
});
/**
* Self-learning backend (Phase C), env-gated.
*
* When the three Intelligence env vars below are all set, the runtime is built
* in Intelligence mode: the local `bankingAgent` still executes here (calling
* OpenAI), but every AG-UI event of every run is streamed over a Phoenix
* WebSocket to the Intelligence gateway for durable threads + self-learning
* ingestion (the `IntelligenceAgentRunner` does both — see
* packages/runtime/src/v2/runtime/runner/intelligence.ts). Officer actions the
* gateway later distills into `/knowledge` are what a fresh agent reads back to
* learn the over-limit unlock unaided.
*
* When ANY of the three is missing, the runtime falls back to the exact OSS
* path: a pure SSE `CopilotRuntime` + `InMemoryAgentRunner`, with no network
* dependency on an Intelligence stack. This is the default and must not regress.
*
* INTELLIGENCE_API_URL e.g. http://localhost:4201
* INTELLIGENCE_GATEWAY_WS_URL e.g. ws://localhost:4401
* INTELLIGENCE_API_KEY e.g. cpk_...
* COPILOTKIT_LICENSE_TOKEN (optional) read automatically by the runtime
*/
const intelligenceApiUrl = process.env.INTELLIGENCE_API_URL;
const intelligenceWsUrl = process.env.INTELLIGENCE_GATEWAY_WS_URL;
const intelligenceApiKey = process.env.INTELLIGENCE_API_KEY;
const intelligenceEnabled = Boolean(
intelligenceApiUrl && intelligenceWsUrl && intelligenceApiKey,
);
/**
* Resolve a stable end-user identity for Intelligence requests.
*
* The client sends the active member's role via CopilotKit `properties`
* ({ userRole }), which the runtime forwards in the request body. We map it to
* a stable per-role user id so threads and distilled knowledge are scoped
* consistently across runs. If the role can't be read, fall back to a single
* stable demo identity rather than minting a random id (random ids would
* fragment thread history).
*
* Some backends verify that the asserted user is a live member of the org
* (e.g. a local Intelligence stack with seeded fixture users). For those, pin
* the identity with INTELLIGENCE_USER_ID / INTELLIGENCE_USER_NAME instead of
* the derived per-role id.
*
* The id/name derivation lives in `@/lib/intelligence/user-id` so the Memory
* panel proxy (api/memories) resolves the exact same per-user scope this
* runtime asserts — the inspector and the agent stay one source of truth.
*/
const identifyUser: IdentifyUserCallback = async (request: Request) => {
let role: string | undefined;
let memberId: string | undefined;
try {
const cloned = request.clone();
const body = (await cloned.json()) as {
properties?: { userRole?: string; userId?: string };
} | null;
role = body?.properties?.userRole;
memberId = body?.properties?.userId;
} catch {
// Non-JSON body (e.g. GET /info) — fall through to the default identity.
}
return {
id: resolveUserId({ memberId, role }),
name: resolveUserName({ memberId, role }),
};
};
function createRuntime(): CopilotRuntime {
if (intelligenceEnabled) {
const intelligence = new CopilotKitIntelligence({
apiUrl: intelligenceApiUrl!,
wsUrl: intelligenceWsUrl!,
apiKey: intelligenceApiKey!,
// Required for the durable-memory demo: the platform's recall_memory /
// save_memory tools live at `${apiUrl}/mcp` and are attached to the local
// BuiltInAgent run via MCP middleware ONLY when this opt-in flag is set
// (see attachIntelligenceEnterpriseLearning in
// packages/runtime/.../handlers/shared/agent-utils.ts). Without it the
// agent has no memory tools and re-offers to record every over-limit charge.
enableEnterpriseLearning: true,
});
return new CopilotRuntime({
agents: { default: bankingAgent },
intelligence,
identifyUser,
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
lockTtlSeconds: 30,
lockKeyPrefix: "northwind-lock",
lockHeartbeatIntervalSeconds: 12,
generateThreadNames: true,
a2ui: { injectA2UITool: false },
openGenerativeUI: { agents: ["default"] },
});
}
// OSS default — pure SSE, no external Intelligence dependency.
return new CopilotRuntime({
agents: { default: bankingAgent },
runner: new InMemoryAgentRunner(),
a2ui: { injectA2UITool: false },
openGenerativeUI: { agents: ["default"] },
});
}
const runtime = createRuntime();
const app = createCopilotHonoHandler({ runtime, basePath: "/api/copilotkit" });
export const GET = handle(app);
export const POST = handle(app);
@@ -0,0 +1,36 @@
import { glassEngineAvailable } from "@/lib/glass-engine";
import { intelligenceEnabled, recallMemories } from "@/lib/intelligence/memory";
import { resolveUserId } from "@/lib/intelligence/user-id";
export async function POST(request: Request): Promise<Response> {
if (!glassEngineAvailable()) {
return new Response("Not Found", { status: 404 });
}
if (!intelligenceEnabled()) {
return Response.json({ error: "intelligence_disabled" }, { status: 503 });
}
let body: { query?: unknown; scope?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: "invalid_json" }, { status: 400 });
}
const query = typeof body.query === "string" ? body.query.trim() : "";
if (!query) return Response.json({ memories: [] });
const scope = typeof body.scope === "string" ? body.scope : undefined;
const identity = {
memberId: request.headers.get("x-northwind-user-id") ?? undefined,
role: request.headers.get("x-northwind-role") ?? undefined,
};
try {
const memories = await recallMemories(
resolveUserId(identity),
query,
scope,
);
return Response.json({ memories });
} catch (err) {
console.error("[api/memories/recall] failed:", err);
return Response.json({ error: "recall_failed" }, { status: 502 });
}
}
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as memoryLib from "@/lib/intelligence/memory";
import { GET } from "./route";
afterEach(() => vi.unstubAllEnvs());
describe("GET /api/memories", () => {
it("404s when Glass Engine is unavailable (public-host default)", async () => {
vi.stubEnv("GLASS_ENGINE_AVAILABLE", "");
// Even with Intelligence configured, an unavailable deployment exposes nothing.
vi.stubEnv("INTELLIGENCE_API_URL", "http://localhost:7050");
vi.stubEnv("INTELLIGENCE_GATEWAY_WS_URL", "ws://localhost:7053");
vi.stubEnv("INTELLIGENCE_API_KEY", "cpk_x");
const res = await GET(new Request("http://localhost:3000/api/memories"));
expect(res.status).toBe(404);
});
it("returns 503 intelligence_disabled when available but in OSS mode", async () => {
vi.stubEnv("GLASS_ENGINE_AVAILABLE", "true");
vi.stubEnv("INTELLIGENCE_API_URL", "");
vi.stubEnv("INTELLIGENCE_GATEWAY_WS_URL", "");
vi.stubEnv("INTELLIGENCE_API_KEY", "");
const res = await GET(new Request("http://localhost:3000/api/memories"));
expect(res.status).toBe(503);
expect(await res.json()).toEqual({ error: "intelligence_disabled" });
});
it("resolves identity from the x-northwind-user-id header (member id)", async () => {
vi.stubEnv("GLASS_ENGINE_AVAILABLE", "true");
vi.stubEnv("INTELLIGENCE_API_URL", "http://localhost:7050");
vi.stubEnv("INTELLIGENCE_GATEWAY_WS_URL", "ws://localhost:7053");
vi.stubEnv("INTELLIGENCE_API_KEY", "cpk_test");
vi.stubEnv("INTELLIGENCE_USER_ID", ""); // unpinned so the map is exercised
const spy = vi
.spyOn(memoryLib, "listRecalledMemories")
.mockResolvedValue([]);
const req = new Request("http://localhost:3000/api/memories", {
headers: { "x-northwind-user-id": "2b3c4d5e6f" }, // Maya -> morgan-fluxx
});
await GET(req);
expect(spy).toHaveBeenCalledWith("morgan-fluxx");
});
});
@@ -0,0 +1,34 @@
import { glassEngineAvailable } from "@/lib/glass-engine";
import {
intelligenceEnabled,
listRecalledMemories,
} from "@/lib/intelligence/memory";
import { resolveUserId } from "@/lib/intelligence/user-id";
/** The active member the panel passes so the proxy resolves the same identity
* the runtime asserts (ignored when INTELLIGENCE_USER_ID is pinned). */
function identityFrom(request: Request) {
return {
memberId: request.headers.get("x-northwind-user-id") ?? undefined,
role: request.headers.get("x-northwind-role") ?? undefined,
};
}
export async function GET(request: Request): Promise<Response> {
// Security boundary: an un-opted-in deployment exposes no memory surface.
if (!glassEngineAvailable()) {
return new Response("Not Found", { status: 404 });
}
if (!intelligenceEnabled()) {
return Response.json({ error: "intelligence_disabled" }, { status: 503 });
}
try {
const memories = await listRecalledMemories(
resolveUserId(identityFrom(request)),
);
return Response.json({ memories });
} catch (err) {
console.error("[api/memories] recall failed:", err);
return Response.json({ error: "recall_failed" }, { status: 502 });
}
}
@@ -0,0 +1,42 @@
import * as store from "@/lib/store";
import type { NextRequest } from "next/server";
// Get policy per card
export const GET = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { id } = await params;
const card = store.findCard(id);
if (!card) {
return new Response(JSON.stringify({ error: "Card not found" }), {
status: 404,
});
}
const policy = card.expensePolicyId
? store.findPolicy(card.expensePolicyId)
: undefined;
return new Response(JSON.stringify(policy), { status: 200 });
};
// Assign policy
export const POST = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const card = store.findCard(id);
if (!card) {
return new Response(JSON.stringify({ error: "Card not found" }), {
status: 404,
});
}
const body = await req.json();
const { policyId } = body;
const updated = store.assignPolicyToCard(id, policyId);
return new Response(JSON.stringify(updated), { status: 201 });
} catch (error) {
console.error("POST Request error", error);
}
};
@@ -0,0 +1,32 @@
import type { NextRequest } from "next/server";
import * as store from "@/lib/store";
export const PUT = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id: cardId } = await params;
// Handle pin or card limit change
const body = await req.json();
const { pin } = body;
if (!pin) {
const card = store.findCard(cardId);
if (!card) {
return new Response(JSON.stringify({ error: "Card not found" }), {
status: 404,
});
}
return new Response(JSON.stringify(card), { status: 200 });
}
const updated = store.updateCardPin(cardId, pin);
if (!updated) {
return new Response(JSON.stringify({ error: "Card not found" }), {
status: 404,
});
}
return new Response(JSON.stringify(updated), { status: 200 });
} catch (error) {
console.error("PUT Request error", error);
}
};
@@ -0,0 +1,29 @@
import type { NextRequest } from "next/server";
import type { Card } from "../data";
import { generateUniqueId } from "../data";
import * as store from "@/lib/store";
export const GET = async () => {
return new Response(JSON.stringify(store.cards()), { status: 200 });
};
export const POST = async (req: NextRequest) => {
try {
// Handle new card creation
const body = await req.json();
const { last4, expiry, type, color, pin } = body;
const newCard: Card = {
id: generateUniqueId(),
last4,
expiry,
type,
color,
pin,
expensePolicyId: "8r5c3m4n5o",
}; // Ensure all required fields are included
store.addCard(newCard);
return new Response(JSON.stringify(newCard), { status: 201 });
} catch (error) {
console.error("POST Request error", error);
}
};
@@ -0,0 +1,97 @@
export enum CardBrand {
Visa = "Visa",
MasterCard = "MasterCard",
}
export const CARD_COLORS = {
[CardBrand.Visa]: "bg-blue-500",
[CardBrand.MasterCard]: "bg-red-500",
};
export interface Card {
id: string;
last4: string;
expiry: string;
type: CardBrand;
color: string;
pin: string;
expensePolicyId?: string;
}
export enum MemberRole {
Admin = "Admin",
Assistant = "Assistant",
Member = "Member",
}
export enum ExpenseRole {
Marketing = "Marketing",
Engineering = "Engineering",
Executive = "Executive",
}
export interface Member {
id: string;
name: string;
email: string;
role: MemberRole;
team: ExpenseRole;
}
export interface ExpensePolicy {
id: string;
type: ExpenseRole;
limit: number;
spent: number;
}
export interface TransactionNote {
content: string;
userId: string;
date: string;
}
export interface PolicyException {
id: string;
transactionId: string;
code: string;
status: "draft" | "approved";
createdAt: string;
}
export interface Transaction {
id: string;
title: string;
note?: TransactionNote;
amount: number;
date: string;
policyId: string;
cardId: string;
status: "pending" | "denied" | "approved";
activeExceptionId?: string | null;
}
export interface NewCardRequest {
type: CardBrand;
color: string;
pin: string;
}
// A copilot-generated report artifact, filed in the dashboard's Reports tab.
// Narrative fields come from the agent; id/createdAt are server-set.
export interface Report {
id: string;
title: string;
summary: string;
highlights: string[];
createdAt: string;
createdBy: string;
}
export function generateUniqueId() {
return Math.random().toString(36).slice(2, 15);
}
// The domain data store has moved to `@/lib/store`. This module is now the
// single source of truth for shared types/enums only. Routes and server
// actions should read/write through the store's typed accessors.
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/store", () => ({ reset: vi.fn() }));
vi.mock("@/lib/intelligence/forget-memories", () => ({
forgetAllMemories: vi.fn().mockResolvedValue(2),
}));
import * as store from "@/lib/store";
import { forgetAllMemories } from "@/lib/intelligence/forget-memories";
import { POST } from "./route";
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("POST /api/v1/dev/reset", () => {
it("403s when presenter reset is disabled and does not touch state", async () => {
vi.stubEnv("PRESENTER_RESET_ENABLED", "");
const res = await POST();
expect(res.status).toBe(403);
expect(store.reset).not.toHaveBeenCalled();
expect(forgetAllMemories).not.toHaveBeenCalled();
});
it("resets the store only when Intelligence is unconfigured", async () => {
vi.stubEnv("PRESENTER_RESET_ENABLED", "true");
vi.stubEnv("INTELLIGENCE_API_URL", "");
vi.stubEnv("INTELLIGENCE_API_KEY", "");
const res = await POST();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true, reset: ["store"] });
expect(store.reset).toHaveBeenCalledTimes(1);
expect(forgetAllMemories).not.toHaveBeenCalled();
});
it("forgets every seeded persona when Intelligence is configured", async () => {
vi.stubEnv("PRESENTER_RESET_ENABLED", "true");
vi.stubEnv("INTELLIGENCE_API_URL", "http://localhost:7050");
vi.stubEnv("INTELLIGENCE_API_KEY", "cpk_test");
const res = await POST();
expect(res.status).toBe(200);
expect(store.reset).toHaveBeenCalledTimes(1);
expect(forgetAllMemories).toHaveBeenCalledWith(
expect.objectContaining({ userId: "jordan-beamson" }),
);
expect(forgetAllMemories).toHaveBeenCalledWith(
expect.objectContaining({ userId: "morgan-fluxx" }),
);
expect(await res.json()).toEqual({
ok: true,
reset: ["store", "memory"],
forgot: 4,
});
});
it("reports partial progress on a mid-loop memory failure", async () => {
vi.stubEnv("PRESENTER_RESET_ENABLED", "true");
vi.stubEnv("INTELLIGENCE_API_URL", "http://localhost:7050");
vi.stubEnv("INTELLIGENCE_API_KEY", "cpk_test");
// First persona succeeds (2 forgotten), second persona throws.
vi.mocked(forgetAllMemories)
.mockResolvedValueOnce(2)
.mockRejectedValueOnce(new Error("boom"));
const res = await POST();
expect(res.status).toBe(502);
expect(await res.json()).toMatchObject({
ok: false,
reset: ["store", "memory"],
forgot: 2,
memoryError: "boom",
});
expect(store.reset).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,57 @@
import * as store from "@/lib/store";
import { forgetAllMemories } from "@/lib/intelligence/forget-memories";
import { presenterResetEnabled } from "@/lib/presenter";
import { SEEDED_USER_IDS } from "@/lib/intelligence/user-id";
/**
* Presenter/booth reset: restore the demo to a fresh "teachable" state.
* Gated by PRESENTER_RESET_ENABLED (same flag the sidebar button checks), so a
* publicly-hosted deployment is safe-off by default.
* 1. Re-seed the in-memory transaction store (over-limit charges back to pending).
* 2. If Intelligence is configured, forget durable memory for EVERY seeded
* persona (a bare list enumerates user + project scope, so the first persona
* also clears project-scoped rows; the rest clear their own user scope).
*/
export const POST = async () => {
if (!presenterResetEnabled()) {
return new Response(JSON.stringify({ error: "presenter reset disabled" }), {
status: 403,
headers: { "content-type": "application/json" },
});
}
store.reset();
const apiUrl = process.env.INTELLIGENCE_API_URL;
const apiKey = process.env.INTELLIGENCE_API_KEY;
if (!apiUrl || !apiKey) {
return new Response(JSON.stringify({ ok: true, reset: ["store"] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
// Declared outside the try so the catch can report partial progress: a
// mid-loop failure can leave the store reset AND some personas already
// forgotten, so the error body must not read as "memory untouched".
let forgot = 0;
try {
for (const userId of SEEDED_USER_IDS) {
forgot += await forgetAllMemories({ apiUrl, apiKey, userId });
}
return new Response(
JSON.stringify({ ok: true, reset: ["store", "memory"], forgot }),
{ status: 200, headers: { "content-type": "application/json" } },
);
} catch (err) {
return new Response(
JSON.stringify({
ok: false,
reset: forgot > 0 ? ["store", "memory"] : ["store"],
forgot,
memoryError: err instanceof Error ? err.message : String(err),
}),
{ status: 502, headers: { "content-type": "application/json" } },
);
}
};
@@ -0,0 +1,37 @@
import * as store from "@/lib/store";
import type { NextRequest } from "next/server";
// Finalize (auto-approve) a draft policy exception, linking it to its
// transaction so the policy-limit gate is lifted when the code justifies it.
export const POST = async (
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const approved = store.finalizePolicyException(id);
return new Response(JSON.stringify(approved), { status: 200 });
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (message === "NOT_FOUND") {
return new Response(
JSON.stringify({ error: "NOT_FOUND", message: "Exception not found" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
}
if (message === "ALREADY_FINALIZED") {
return new Response(
JSON.stringify({
error: "BAD_REQUEST",
message: "Exception already finalized.",
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
console.error("POST Request error", error);
return new Response(
JSON.stringify({ error: "BAD_REQUEST", message: "Bad request" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
};
@@ -0,0 +1,44 @@
import type { NextRequest } from "next/server";
import * as store from "@/lib/store";
// Open a draft policy exception against a transaction.
export const POST = async (req: NextRequest) => {
let code: string | undefined;
try {
const body = await req.json();
code = body?.code;
const exception = store.openPolicyException(
body?.transactionId,
code as string,
);
return new Response(JSON.stringify(exception), { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (message === "NOT_FOUND") {
return new Response(
JSON.stringify({
error: "NOT_FOUND",
message: "Transaction not found",
}),
{ status: 404, headers: { "content-type": "application/json" } },
);
}
if (message === "INVALID_EXCEPTION_CODE") {
// Surface-level rejection. We deliberately do NOT enumerate the valid
// codes here — that would itself be a hint. The agent has to find the
// catalogue on its own via `/knowledge`.
return new Response(
JSON.stringify({
error: "INVALID_EXCEPTION_CODE",
message: `"${code}" is not a recognized policy exception code.`,
}),
{ status: 422, headers: { "content-type": "application/json" } },
);
}
console.error("POST Request error", error);
return new Response(
JSON.stringify({ error: "BAD_REQUEST", message: "Bad request" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
};
@@ -0,0 +1,14 @@
import { MemberRole } from "@/app/api/v1/data";
const AdminAndAssistants = [MemberRole.Admin, MemberRole.Assistant];
const All = [MemberRole.Admin, MemberRole.Assistant, MemberRole.Member];
const AdminOnly = [MemberRole.Admin];
export const PERMISSIONS = {
ADD_CARD: AdminOnly,
ADD_POLICY: AdminAndAssistants,
ADD_NOTE: All,
SHOW_TRANSACTIONS: All,
SET_PIN: All,
APPROVE_TRANSACTION: AdminAndAssistants,
};
@@ -0,0 +1,27 @@
import type { NextRequest } from "next/server";
import * as store from "@/lib/store";
// Get policy per card
export const GET = async () => {
return new Response(JSON.stringify(store.policies()), { status: 200 });
};
// Assign policy
export const POST = async (req: NextRequest) => {
try {
const body = await req.json();
const { policyId, type, limit } = body;
if (!policyId || !type || !limit) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{ status: 400 },
);
}
// Handle new policy creation
const newPolicy = { id: policyId, type, limit, spent: 0 };
store.addPolicy(newPolicy);
return new Response(JSON.stringify(newPolicy), { status: 201 });
} catch (error) {
console.error("POST Request error", error);
}
};
@@ -0,0 +1,76 @@
/**
* Policy-exception-code catalogue for the banking demo.
*
* The human UI displays the `label` so a compliance officer knows what
* each code means; everything stored in the exception record and returned
* by the agent tools carries only the `code`. The agent has no static
* mapping from code -> meaning — it must discover via `/knowledge` which
* codes actually justify an approval. That's the whole point of this
* surface for the SL demo: the writer agent watches successful officer
* flows and learns that, e.g., `EXC-BOARD-APPROVED` is the
* approval-justifying lever for policy overrides, while the other codes
* are filed for recordkeeping but do not constitute a standing
* justification.
*
* Adding a new code? Append it here. Adding a NEW justifying code?
* Also list it in `JUSTIFYING_EXCEPTION_CODES` below.
*/
export interface PolicyExceptionCodeMeta {
readonly code: string;
readonly label: string;
}
/**
* Catalogue shown to the human in the New exception modal. First three
* are the justifying codes. The rest are plausible exception reasons
* that get filed for the record but do not constitute a standing policy
* justification.
*/
export const POLICY_EXCEPTION_CODES: ReadonlyArray<PolicyExceptionCodeMeta> = [
// Justifying — these codes support approval of the policy exception.
{ code: "EXC-BOARD-APPROVED", label: "Board-approved spend" },
{ code: "EXC-CONTRACTUAL-COMMITMENT", label: "Contractual commitment" },
{ code: "EXC-EMERGENCY-SPEND", label: "Emergency spend" },
// Non-justifying. Recorded for history; do not constitute a standing justification.
{ code: "EXC-WILL-REIMBURSE", label: "Employee will reimburse" },
{ code: "EXC-ONE-TIME", label: "One-time exception" },
];
/**
* The three codes that, when filed and finalized, justify approval of
* a policy exception. Anything else is recorded but does not provide
* a standing justification.
*/
export const JUSTIFYING_EXCEPTION_CODES: ReadonlySet<string> = new Set<string>([
"EXC-BOARD-APPROVED",
"EXC-CONTRACTUAL-COMMITMENT",
"EXC-EMERGENCY-SPEND",
]);
/**
* Human-friendly label for a policy exception code. Falls back to the
* raw code for unknown values (e.g. legacy records from older seeds).
*/
export const labelForExceptionCode = (code: string): string =>
POLICY_EXCEPTION_CODES.find((c) => c.code === code)?.label ?? code;
const VALID_EXCEPTION_CODES: ReadonlySet<string> = new Set(
POLICY_EXCEPTION_CODES.map((c) => c.code),
);
/**
* True iff `code` belongs to the published catalogue. The store rejects
* `openPolicyException` calls that pass an unknown code, forcing the
* agent to learn the catalogue via `/knowledge` rather than inventing
* plausible-looking strings.
*/
export const isValidExceptionCode = (code: string): boolean =>
VALID_EXCEPTION_CODES.has(code);
/**
* True iff `code` is one of the three justifying exception codes.
* Non-justifying codes are filed for history but do not support approval.
*/
export const isJustifying = (code: string): boolean =>
JUSTIFYING_EXCEPTION_CODES.has(code);
@@ -0,0 +1,30 @@
import * as store from "@/lib/store";
// Get all reports (newest first — the store unshifts on file)
export const GET = async () => {
return new Response(JSON.stringify(store.reports()), { status: 200 });
};
// File a new copilot-generated report
export const POST = async (req: Request) => {
const body = await req.json().catch(() => null);
const title = typeof body?.title === "string" ? body.title.trim() : "";
const summary = typeof body?.summary === "string" ? body.summary.trim() : "";
const highlights = Array.isArray(body?.highlights)
? body.highlights.filter((h: unknown): h is string => typeof h === "string")
: [];
const createdBy =
typeof body?.createdBy === "string" && body.createdBy.trim()
? body.createdBy.trim()
: "Copilot";
if (!title || !summary) {
return new Response(
JSON.stringify({ error: "title and summary are required" }),
{ status: 400 },
);
}
const report = store.addReport({ title, summary, highlights, createdBy });
return new Response(JSON.stringify(report), { status: 201 });
};
@@ -0,0 +1,52 @@
import * as store from "@/lib/store";
import type { Transaction } from "../../data";
import type { NextRequest } from "next/server";
// Add note to transaction / update status
export const PUT = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const existing = store.findTransaction(id);
if (!existing) {
return new Response(JSON.stringify({ error: "Transaction not found" }), {
status: 404,
});
}
const body = await req.json();
const { content, userId, ...rest } = body;
const patch: Partial<Transaction> = { ...rest };
if (content && userId) {
patch.note = {
content,
userId,
date: new Date().toISOString().split("T")[0],
};
}
// Policy-limit gate. `status` arrives inside `rest`, so apply the patch
// to a merged view before checking. The rejection names ONLY the
// symptom (the policy is over its limit) — never the policy-exception
// path that lifts the gate. The agent has to learn that recipe from
// `/knowledge`; leaking it here would defeat the SL demo.
const merged = { ...existing, ...patch };
if (
patch.status === "approved" &&
!store.isWithinPolicyLimit(merged) &&
!store.hasApprovedException(merged)
) {
return new Response(
JSON.stringify({
error: "OVER_POLICY_LIMIT",
message: `${store.findPolicy(existing.policyId)?.type} policy limit exceeded`,
}),
{ status: 422, headers: { "content-type": "application/json" } },
);
}
const updated = store.updateTransaction(id, patch);
return new Response(JSON.stringify(updated), { status: 201 });
} catch (error) {
console.error("PUT Request error", error);
}
};
@@ -0,0 +1,6 @@
import * as store from "@/lib/store";
// Get all transactions
export const GET = async () => {
return new Response(JSON.stringify(store.transactions()), { status: 200 });
};
@@ -0,0 +1,41 @@
import type { NextRequest } from "next/server";
import * as store from "@/lib/store";
export const PUT = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
// Handle role/team change
const body = await req.json();
const { team, role } = body;
const updated = store.updateMember(id, { team, role });
if (!updated) {
return new Response(JSON.stringify({ error: "User not found" }), {
status: 404,
});
}
return new Response(JSON.stringify(updated), { status: 200 });
} catch (error) {
console.error("PUT Request error", error);
}
};
export const DELETE = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const remaining = store.removeMember(id);
if (!remaining) {
return new Response(JSON.stringify({ error: "User not found" }), {
status: 404,
});
}
return new Response(JSON.stringify(remaining), { status: 200 });
} catch (error) {
console.error("DELETE Request error", error);
}
};
@@ -0,0 +1,20 @@
import type { NextRequest } from "next/server";
import { generateUniqueId } from "../data";
import * as store from "@/lib/store";
export const GET = async () => {
return new Response(JSON.stringify(store.team()), { status: 200 });
};
export const POST = async (req: NextRequest) => {
try {
// Handle new item creation
const body = await req.json();
const { name, email, role, team } = body;
const newUser = { id: generateUniqueId(), name, email, role, team };
store.addMember(newUser);
return new Response(JSON.stringify(newUser), { status: 201 });
} catch (error) {
console.error("POST Request error", error);
}
};
@@ -0,0 +1,5 @@
"use client";
// The `/cards` route mirrors the dashboard overview (it shares the same
// `useCreditCards` data). Re-export the redesigned dashboard so both routes
// stay visually identical without duplicating the layout.
export { default } from "@/app/dashboard/page";
@@ -0,0 +1,407 @@
"use client";
import useCreditCards from "@/app/actions";
import { useMemo, useState } from "react";
import type { Transaction } from "@/app/api/v1/data";
import { ArrowDownRight, ArrowUpRight, Plus, ArrowRight } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TransactionsList } from "@/components/transactions-list";
import { GradientCreditCard } from "@/components/card-visual";
import { StatisticsChart } from "@/components/statistics-chart";
import { AnalyticsView } from "@/components/wow/analytics-view";
import { ReportsView } from "@/components/wow/reports-view";
import { useAuthContext } from "@/components/auth-context";
import { useRecording } from "@/components/recording-context";
import { formatCurrency } from "@/lib/utils";
import Link from "next/link";
export default function HomePage() {
const {
cards,
policies,
transactions,
changeTransactionStatus,
openPolicyException,
finalizePolicyException,
} = useCreditCards();
const { currentUser } = useAuthContext();
const { logStep } = useRecording();
// Top-level dashboard tab; the "View All" link jumps straight to Transactions.
const [tab, setTab] = useState("overview");
// Switch the top-level tab and narrate it into the recorder HUD (only while a
// workflow is being recorded). Used by both the tab strip and the "View All"
// shortcut so either path records the same "Opened Transactions" step.
const selectTab = (value: string) => {
setTab(value);
if (value === "transactions") logStep("Opened Transactions");
};
// Charges awaiting approval. Over-limit ones surface the "File policy
// exception" affordance inside TransactionsList; the approve only takes
// effect once a justifying exception is finalized (the gate in store.ts).
const pendingTransactions = transactions.filter(
(t) => t.status === "pending",
);
// Run the REST mutation and report whether it actually took effect, so
// TransactionsList only records the human action when the server accepted it
// (a blocked over-limit approval must not be recorded as an approval).
const handleChangeTransactionStatus = async ({
id,
status,
}: {
id: string;
status: Transaction["status"];
}): Promise<boolean> => {
const { ok } = await changeTransactionStatus({ id, status });
return ok;
};
const { balance, income, expenses, limit, lastPayment, stats, statLabels } =
useMemo(() => {
const { balance, limit } = policies.reduce(
(acc, policy) => ({
balance: acc.balance + policy.spent,
limit: {
used: acc.limit.used + policy.spent,
total: acc.limit.total + policy.limit,
},
}),
{ balance: 0, limit: { used: 0, total: 0 } },
);
const income = transactions
.filter((t) => t.amount > 0)
.reduce((sum, t) => sum + t.amount, 0);
const expenses = transactions
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
// Most recent transaction by date = "last payment".
const lastPayment = [...transactions].sort(
(a, b) => +new Date(b.date) - +new Date(a.date),
)[0];
// Statistics: bucket expense magnitude by calendar month (oldest→newest).
// Falls back to representative seeded points when there isn't enough data.
const byMonth = new Map<string, number>();
for (const t of transactions) {
if (t.amount >= 0) continue;
const d = new Date(t.date);
if (Number.isNaN(d.getTime())) continue;
const key = `${d.getFullYear()}-${d.getMonth()}`;
byMonth.set(key, (byMonth.get(key) ?? 0) + Math.abs(t.amount));
}
const monthFmt = new Intl.DateTimeFormat("en-US", { month: "short" });
const sortedMonths = [...byMonth.entries()].sort(([a], [b]) => {
const [ay, am] = a.split("-").map(Number);
const [by, bm] = b.split("-").map(Number);
return ay - by || am - bm;
});
let stats: number[];
let statLabels: string[];
if (sortedMonths.length >= 3) {
stats = sortedMonths.map(([, v]) => v);
statLabels = sortedMonths.map(([k]) => {
const [y, m] = k.split("-").map(Number);
return monthFmt.format(new Date(y, m, 1));
});
} else {
stats = [3200, 4100, 3600, 5200, 4800, 6400];
statLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
}
return {
balance,
income,
expenses,
limit: {
total: limit.total,
usagePercentage: limit.total ? (limit.used / limit.total) * 100 : 0,
},
lastPayment,
stats,
statLabels,
};
}, [policies, transactions]);
const holderName = currentUser?.name?.toUpperCase() ?? "CARD HOLDER";
const primaryCard = cards[0];
const secondaryCard = cards[1];
return (
<div className="space-y-6 px-2 pb-4 md:px-4">
<Tabs value={tab} onValueChange={selectTab} className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold tracking-tight text-ink">
Dashboard
</h2>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="transactions">Transactions</TabsTrigger>
<TabsTrigger value="analytics">Analytics</TabsTrigger>
<TabsTrigger value="reports">Reports</TabsTrigger>
</TabsList>
</div>
<TabsContent
value="overview"
className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_360px]"
>
{/* ── LEFT column ──────────────────────────────────────────── */}
<div className="space-y-6">
{/* My Cards */}
<section>
<div className="mb-4 flex items-center justify-between">
<h3 className="section-heading text-base">My Cards</h3>
<Link
href="/"
className="text-sm font-medium text-brand-indigo hover:underline dark:text-brand-violet"
>
View all
</Link>
</div>
<div className="flex gap-5 overflow-x-auto pb-2">
{primaryCard && (
<div className="relative w-[300px] flex-shrink-0">
{/* A second card peeking behind */}
{secondaryCard && (
<div className="absolute -right-3 top-3 w-full scale-[0.96] opacity-60 blur-[1px]">
<GradientCreditCard
card={secondaryCard}
holder={holderName}
subtle
/>
</div>
)}
<div className="relative">
<GradientCreditCard
card={primaryCard}
holder={holderName}
/>
</div>
</div>
)}
{/* Add-card tile (rendered last) */}
<Link
href="/"
aria-label="Add a new card"
className="flex aspect-[1.586/1] w-[300px] flex-shrink-0 flex-col items-center justify-center gap-2 rounded-[22px] border-2 border-dashed border-brand/40 text-ink-muted transition-colors hover:border-brand hover:bg-brand-soft/50 hover:text-brand-indigo"
>
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-brand-soft text-brand-indigo">
<Plus className="h-6 w-6" />
</span>
<span className="text-sm font-medium">Add new card</span>
</Link>
</div>
</section>
{/* Recent Transactions */}
<section className="rounded-2xl border border-hairline bg-surface p-5 shadow-soft">
<div className="mb-3 flex items-center justify-between">
<h3 className="section-heading text-base">
Recent Transactions
</h3>
<button
type="button"
onClick={() => selectTab("transactions")}
className="text-sm font-medium text-brand-indigo hover:underline dark:text-brand-violet"
>
View All
</button>
</div>
<Tabs defaultValue="all">
<TabsList variant="underline" className="mb-2">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="income">Income</TabsTrigger>
<TabsTrigger value="expenses">Expenses</TabsTrigger>
</TabsList>
<div className="mt-4 mb-1">
<span className="inline-flex items-center rounded-full bg-brand-soft px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-wide text-brand-indigo dark:text-brand-violet">
Today
</span>
</div>
<TabsContent value="all">
<TransactionsList transactions={transactions} />
</TabsContent>
<TabsContent value="income">
<TransactionsList
transactions={transactions.filter((t) => t.amount > 0)}
/>
</TabsContent>
<TabsContent value="expenses">
<TransactionsList
transactions={transactions.filter((t) => t.amount < 0)}
/>
</TabsContent>
</Tabs>
</section>
</div>
{/* ── RIGHT rail ──────────────────────────────────────────── */}
<aside className="flex flex-col gap-5 rounded-[26px] border border-hairline bg-surface p-6 shadow-soft">
<div>
<p className="text-sm text-ink-muted">Balance</p>
<p className="mt-1 text-4xl font-bold tracking-tight text-ink">
{formatCurrency(balance)}
</p>
{primaryCard && (
<p className="mt-2 font-mono text-xs tracking-widest text-ink-muted">
{primaryCard.last4}
</p>
)}
</div>
{/* Income / Expenses split */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl bg-positive-soft/60 p-3">
<div className="flex items-center gap-1.5 text-positive">
<ArrowUpRight className="h-4 w-4" />
<span className="text-xs font-medium">Income</span>
</div>
<p className="mt-1 text-lg font-bold text-ink">
{formatCurrency(income)}
</p>
</div>
<div className="rounded-2xl bg-negative-soft/60 p-3">
<div className="flex items-center gap-1.5 text-negative">
<ArrowDownRight className="h-4 w-4" />
<span className="text-xs font-medium">Expenses</span>
</div>
<p className="mt-1 text-lg font-bold text-ink">
{formatCurrency(expenses)}
</p>
</div>
</div>
<div className="h-px w-full bg-hairline" />
{/* Last payment */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-ink-muted">
Last Payment Details
</p>
{lastPayment ? (
<div className="mt-2 flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-ink">
{lastPayment.title}
</p>
<p className="text-xs text-ink-muted">{lastPayment.date}</p>
</div>
<p
className={
lastPayment.amount > 0
? "font-semibold text-positive"
: "font-semibold text-negative"
}
>
{lastPayment.amount > 0 ? "+" : ""}
{formatCurrency(lastPayment.amount)}
</p>
</div>
) : (
<p className="mt-2 text-sm text-ink-muted">No payments yet</p>
)}
</div>
{/* Statistics */}
<div>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-ink-muted">
Statistics
</p>
<span className="text-[0.7rem] text-ink-muted">
{formatCurrency(limit.total)} limit ·{" "}
{limit.usagePercentage.toFixed(0)}% used
</span>
</div>
<StatisticsChart data={stats} labels={statLabels} />
</div>
{/* CTA */}
<Link
href="/"
className="brand-gradient mt-1 flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-semibold text-white shadow-[0_10px_24px_hsl(252_83%_60%/0.35)] transition-all hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
New Transaction
<ArrowRight className="h-4 w-4" />
</Link>
</aside>
</TabsContent>
<TabsContent value="transactions">
<section className="rounded-2xl border border-hairline bg-surface p-5 shadow-soft">
<div className="mb-3 flex items-center justify-between">
<h3 className="section-heading text-base">All Transactions</h3>
</div>
<Tabs
defaultValue="all"
onValueChange={(value) => {
if (value === "pending") logStep("Opened Pending approval");
}}
>
<TabsList variant="underline" className="mb-2">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="pending">Pending approval</TabsTrigger>
<TabsTrigger value="income">Income</TabsTrigger>
<TabsTrigger value="expenses">Expenses</TabsTrigger>
</TabsList>
<TabsContent value="all">
<TransactionsList transactions={transactions} />
</TabsContent>
<TabsContent value="pending">
{pendingTransactions.length ? (
<TransactionsList
transactions={pendingTransactions}
policies={policies}
openPolicyException={openPolicyException}
finalizePolicyException={finalizePolicyException}
showApprovalInterface
approvalInterfaceProps={{
onApprove: (id) =>
handleChangeTransactionStatus({
id,
status: "approved",
}),
onDeny: (id) =>
handleChangeTransactionStatus({ id, status: "denied" }),
}}
/>
) : (
<p className="px-3 py-8 text-center text-sm text-ink-muted">
No transactions are pending approval.
</p>
)}
</TabsContent>
<TabsContent value="income">
<TransactionsList
transactions={transactions.filter((t) => t.amount > 0)}
/>
</TabsContent>
<TabsContent value="expenses">
<TransactionsList
transactions={transactions.filter((t) => t.amount < 0)}
/>
</TabsContent>
</Tabs>
</section>
</TabsContent>
<TabsContent value="analytics">
<AnalyticsView />
</TabsContent>
<TabsContent value="reports">
<ReportsView />
</TabsContent>
</Tabs>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,278 @@
@import "tailwindcss";
@import "tw-animate-css";
/* Banking app uses a class-based dark mode (`.dark` / `.light` on <html>). In
Tailwind v4 the default dark variant is `prefers-color-scheme`; the
`@custom-variant` directive below re-enables the class-based behavior so
utilities like `dark:bg-neutral-800` continue to work. */
@custom-variant dark (&:where(.dark, .dark *));
/* ──────────────────────────────────────────────────────────────────────────
DESIGN TOKENS — "Aurora" premium light-theme fintech language.
Mood: airy, premium, modern. Soft lavender canvas, white glass cards, a
violet→indigo primary gradient, emerald (positive) / rose (negative)
semantics, large radii and soft shadows. See docs/DESIGN.md.
The legacy shadcn `--background` / `--foreground` HSL pairs are preserved
(Tailwind utilities such as `bg-background` still resolve through `@theme
inline` below) so nothing that referenced them breaks. New work should reach
for the named brand tokens (`--brand-*`, `--surface`, `--positive`,
`--negative`) and the helper utility classes at the bottom of this file.
────────────────────────────────────────────────────────────────────────── */
@theme inline {
/* Legacy shadcn bridges (kept for back-compat with existing utilities). */
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
/* Brand palette, exposed as Tailwind colors so `bg-brand`, `text-brand`,
`from-brand-violet`, `to-brand-indigo`, `ring-brand` all work. */
--color-brand: hsl(var(--brand));
--color-brand-violet: hsl(var(--brand-violet));
--color-brand-indigo: hsl(var(--brand-indigo));
--color-brand-foreground: hsl(var(--brand-foreground));
--color-brand-soft: hsl(var(--brand-soft));
--color-surface: hsl(var(--surface));
--color-surface-muted: hsl(var(--surface-muted));
--color-canvas: hsl(var(--canvas));
--color-ink: hsl(var(--ink));
--color-ink-muted: hsl(var(--ink-muted));
--color-positive: hsl(var(--positive));
--color-positive-soft: hsl(var(--positive-soft));
--color-negative: hsl(var(--negative));
--color-negative-soft: hsl(var(--negative-soft));
--color-hairline: hsl(var(--hairline));
/* Radius scale — large and friendly. `--radius` is the card baseline. */
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 6px);
--radius-sm: calc(var(--radius) - 10px);
--radius-xl: calc(var(--radius) + 6px);
--radius-2xl: calc(var(--radius) + 12px);
/* Soft, layered shadows. */
--shadow-soft:
0 1px 2px hsl(252 60% 30% / 0.04), 0 8px 24px hsl(252 50% 30% / 0.06);
--shadow-lift:
0 2px 6px hsl(252 60% 30% / 0.06), 0 18px 48px hsl(252 50% 30% / 0.12);
--shadow-glow: 0 12px 30px hsl(252 83% 60% / 0.35);
/* Headings pick up the brand font (Inter via next/font), body too. */
--font-sans:
var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI",
sans-serif;
}
body {
font-family:
var(--font-inter),
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
Helvetica,
Arial,
sans-serif;
background-color: hsl(var(--canvas));
color: hsl(var(--ink));
}
@layer base {
:root {
/* Card baseline radius (≈22px). */
--radius: 1.375rem;
/* Brand violet→indigo. violet-600 ≈ #7C5CFC, indigo-600 ≈ #5B3DF5. */
--brand: 252 83% 67%; /* primary violet (#7C5CFC-ish) */
--brand-violet: 252 83% 67%;
--brand-indigo: 248 84% 60%; /* indigo (#5B3DF5-ish) */
--brand-foreground: 0 0% 100%;
--brand-soft: 252 90% 96%; /* lilac chip / hover wash */
}
/* LIGHT (primary showcase mode). */
.light {
--background: 255 60% 99%;
--foreground: 252 30% 14%;
color-scheme: light;
--canvas: 255 60% 97%; /* soft lavender page background */
--surface: 0 0% 100%; /* white cards */
--surface-muted: 252 40% 98%;
--ink: 252 30% 14%; /* near-black headings */
--ink-muted: 250 12% 46%; /* muted grey labels */
--hairline: 252 30% 92%; /* soft borders */
--positive: 152 62% 40%; /* emerald */
--positive-soft: 152 70% 95%;
--negative: 349 78% 56%; /* rose / red */
--negative-soft: 349 90% 96%;
}
/* DARK (kept fully working — deep indigo-tinted neutrals). */
.dark {
--background: 252 30% 7%;
--foreground: 250 30% 96%;
color-scheme: dark;
--canvas: 252 30% 7%;
--surface: 252 24% 11%;
--surface-muted: 252 22% 14%;
--ink: 250 30% 96%;
--ink-muted: 250 12% 66%;
--hairline: 252 20% 22%;
--brand-soft: 252 50% 18%;
--positive: 152 56% 50%;
--positive-soft: 152 40% 16%;
--negative: 349 80% 64%;
--negative-soft: 349 40% 18%;
}
}
/* ── Reusable brand helpers ─────────────────────────────────────────────── */
@layer components {
/* The signature violet→indigo gradient, reused on the primary button, the
credit card, and CTAs. */
.brand-gradient {
background-image: linear-gradient(
135deg,
hsl(var(--brand-violet)),
hsl(var(--brand-indigo))
);
}
.brand-text-gradient {
background-image: linear-gradient(
135deg,
hsl(var(--brand-violet)),
hsl(var(--brand-indigo))
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Glassmorphism-lite surface used by cards and the floating sidebar. */
.glass-surface {
background-color: hsl(var(--surface) / 0.85);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
}
/* Section heading — medium-weight violet. */
.section-heading {
color: hsl(var(--brand-indigo));
font-weight: 600;
letter-spacing: -0.01em;
}
.dark .section-heading {
color: hsl(var(--brand-violet));
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
.prefix-arrow::before {
content: "↑ ";
}
/* ── CopilotKit chat popup accent ───────────────────────────────────────────
The v2 chat scopes its shadcn-style tokens to `[data-copilotkit]` (neutral
greys by default). We re-point only the accent-ish tokens to the brand
violet so the send button, focus rings and links match — purely additive,
no component props touched, and it degrades gracefully if the SDK changes. */
[data-copilotkit] {
--primary: oklch(58% 0.214 277);
--primary-foreground: oklch(98.5% 0 0);
--ring: oklch(58% 0.214 277);
}
.dark [data-copilotkit],
[data-copilotkit].dark {
--primary: oklch(70% 0.17 280);
--primary-foreground: oklch(20.5% 0 0);
--ring: oklch(70% 0.17 280);
}
/* ── Spacing between stacked chat action cards ──────────────────────────────
HITL / tool-render cards (openPolicyException, approveTransaction, the
teach-loop cards, …) render as bordered blocks inside assistant messages.
CopilotKit lays the message list out as a flex column with no row-gap and
gives messages no margin, so a recall chain (open → finalize → approve)
renders several cards sharing an edge — both across consecutive assistant
messages and stacked within one. A uniform top gap on every card separates
them in both cases (the first card's gap is harmless against the chat top). */
.copilotKitMessages .rounded-2xl.border-hairline {
margin-top: 0.75rem;
}
/* ── Legacy dark-mode overrides, retargeted to the new tokens ───────────── */
.dark body {
background-color: hsl(var(--canvas));
color: hsl(var(--ink));
}
.dark header,
.dark .border-b,
.dark .border-r {
border-color: hsl(var(--hairline));
}
/* ── Self-learning "recording" vignette ─────────────────────────────────────
A soft pulsating violet glow that hugs the canvas edges while the agent is
recording a demonstrated officer action (see <RecordingVignette/> +
recording-context.tsx). Non-blocking overlay: fixed, full-viewport,
pointer-events:none, driven by the `data-recording` attribute. */
@keyframes recording-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
.recording-vignette {
position: fixed;
inset: 0;
/* Above the docked chat panel (z-1200) so the glow frames the WHOLE canvas —
including over the panel — reading as "the entire app is being recorded".
pointer-events:none keeps it from ever intercepting clicks. */
z-index: 1300;
pointer-events: none;
opacity: 0;
transition: opacity 220ms ease-in-out;
box-shadow:
inset 0 0 0 3px hsl(var(--brand-violet) / 0.6),
inset 0 0 70px 2px hsl(var(--brand-violet) / 0.45),
inset 0 0 170px 36px hsl(var(--brand-indigo) / 0.22);
}
.recording-vignette[data-recording="true"] {
opacity: 1;
/* Delay the pulse by the fade-in duration so opacity eases 0 → 1 first, then
breathes; the keyframe starts at opacity 1 to avoid a snap. */
animation: recording-pulse 2.4s ease-in-out 0.22s infinite;
}
@media (prefers-reduced-motion: reduce) {
.recording-vignette[data-recording="true"] {
animation: none;
opacity: 0.45;
}
}
/* Radix popper overlays (Select, DropdownMenu, Popover, Tooltip) portal to
<body> at z-50, so when opened inside the docked chat sidebar (z-1200) they
render BEHIND the panel and look like nothing happened. Lift every Radix
popper above the panel — same 1300 "above the chat panel" value used by the
recording vignette above. One rule fixes the whole class of overlay. */
[data-radix-popper-content-wrapper] {
z-index: 1300 !important;
}
@@ -0,0 +1,60 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { Inter } from "next/font/google";
import "@copilotkit/react-core/v2/styles.css";
import "./globals.css";
import { AuthContextProvider } from "@/components/auth-context";
import { CopilotKitWrapper } from "./wrapper";
import { IDENTITY } from "@/lib/identity";
import { glassEngineAvailable } from "@/lib/glass-engine";
import { presenterResetEnabled } from "@/lib/presenter";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
// Inter is the body + heading typeface for the premium fintech look. Loaded
// via next/font/google (part of Next — no new dependency). Exposed as
// `--font-inter`, which globals.css maps onto `--font-sans`.
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
export const metadata: Metadata = {
title: IDENTITY.brand,
description: "Collaborative finance for 21st century teams",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="light" suppressHydrationWarning>
<body
className={`${inter.variable} ${geistSans.variable} ${geistMono.variable} antialiased`}
>
<AuthContextProvider>
{/* Read the deployment gate server-side (non-NEXT_PUBLIC_ env) and
thread it to the client as a prop — one image, per-deploy env. */}
<CopilotKitWrapper
glassAvailable={glassEngineAvailable()}
resetEnabled={presenterResetEnabled()}
>
{children}
</CopilotKitWrapper>
</AuthContextProvider>
</body>
</html>
);
}
+578
View File
@@ -0,0 +1,578 @@
"use client";
import { useEffect, useReducer } from "react";
import {
useHumanInTheLoop,
useComponent,
useFrontendTool,
} from "@copilotkit/react-core/v2";
import { z } from "zod";
import type { NewCardRequest } from "@/app/api/v1/data";
import { CARD_COLORS, CardBrand } from "@/app/api/v1/data";
import { CreditCardDetails } from "@/components/credit-card-details";
import { CardPicker } from "@/components/card-picker";
import type { PartialBy } from "@/lib/type-helpers";
import {
filterTransactionByTitle,
filterTransactionsByCardLast4,
filterTransactionsByPolicyId,
randomDigits,
} from "@/lib/utils";
import useCreditCards from "@/app/actions";
import { useAuthContext } from "@/components/auth-context";
import { AddCardDropdown } from "@/components/add-card-dropdown";
import { TransactionsList } from "@/components/transactions-list";
import { ChangePinDialog } from "@/components/change-pin-dialog";
import { useSearchParams } from "next/navigation";
import { CardsPageOperations } from "@/components/copilot-context";
import { PERMISSIONS } from "@/app/api/v1/permissions";
import { ApprovalButtons } from "@/components/approval-buttons";
interface ChangePinState {
newPin: string;
dialogOpen: boolean;
cardId: string | null;
loading: boolean;
}
const changePinReducer = (
state: ChangePinState,
payload: Partial<ChangePinState>,
): ChangePinState => ({
...state,
...payload,
});
export default function Page() {
const { currentUser } = useAuthContext();
const searchParams = useSearchParams();
const operation = searchParams.get("operation") as CardsPageOperations | null;
const [state, dispatch] = useReducer(changePinReducer, {
newPin: "",
dialogOpen: false,
cardId: null,
loading: false,
});
const {
cards,
policies,
transactions,
addNewCard,
changePin,
assignPolicyToCard,
addNoteToTransaction,
} = useCreditCards();
useEffect(() => {
const operationNameToMethod: Partial<
Record<CardsPageOperations, () => void>
> = {
[CardsPageOperations.ChangePin]: () => dispatch({ dialogOpen: true }),
};
if (!operation || !Object.values(CardsPageOperations).includes(operation))
return;
operationNameToMethod[operation]?.();
}, [operation]);
const handleChangePinSubmit = async ({
pin,
cardId,
}: {
pin?: string;
cardId?: string;
}) => {
dispatch({ loading: true });
await changePin({
pin: pin ?? state.newPin,
cardId: cardId ?? state.cardId!,
});
dispatch({ loading: false, newPin: "", cardId: null, dialogOpen: false });
};
const handleAddCard = async (
cardRequest: PartialBy<NewCardRequest, "color" | "pin">,
) => {
void addNewCard({
...cardRequest,
color: CARD_COLORS[cardRequest.type],
pin: randomDigits(4).toString(),
});
};
// Enable add new card with co pilot (human-in-the-loop)
useHumanInTheLoop({
followUp: false,
name: "addNewCard",
description:
"Add new credit card. You MUST ask the user for the PIN (4 digits) before calling this action. Once you have all required info, call this action immediately without asking for additional confirmation - the approval UI will handle that.",
available: PERMISSIONS.ADD_CARD.includes(currentUser.role),
parameters: z.object({
type: z
.string()
.describe("The type of the card (set by user), Visa or Mastercard"),
color: z
.string()
.describe(
"The color of the card (generated by copilot, bg-blue-500 for visa, bg-red-500 for mastercard)",
),
pin: z
.string()
.describe("The pin code of the card (set by user), 4 digits"),
}),
render: ({ args, respond, status }) => {
const { type, color, pin } = args;
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">New Card Request</h3>
<div className="flex items-center gap-3">
<div className="flex h-7 w-10 items-center justify-center rounded-md border border-hairline bg-surface p-1">
{type === CardBrand.Visa ? (
<svg
className="h-5"
viewBox="0 0 780 500"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M293.2 348.7l33.4-195.8h53.4l-33.4 195.8zM540.7 157.2c-10.6-4-27.2-8.3-47.9-8.3-52.8 0-90 26.6-90.2 64.6-.3 28.1 26.5 43.8 46.8 53.2 20.8 9.6 27.8 15.7 27.7 24.3-.1 13.1-16.6 19.1-32 19.1-21.4 0-32.7-3-50.3-10.2l-6.9-3.1-7.5 43.8c12.5 5.5 35.6 10.2 59.6 10.5 56.2 0 92.6-26.3 93-66.8.2-22.3-14-39.2-44.8-53.2-18.6-9.1-30.1-15.1-30-24.3 0-8.1 9.7-16.8 30.6-16.8 17.4-.3 30.1 3.5 39.9 7.5l4.8 2.3 7.2-42.7zM676.3 152.9h-41.3c-12.8 0-22.4 3.5-28 16.3l-79.4 179.5h56.2s9.2-24.2 11.3-29.5c6.1 0 60.8.1 68.6.1 1.6 6.9 6.5 29.4 6.5 29.4h49.7l-43.6-195.8zm-65.8 126.3c4.4-11.3 21.4-54.8 21.4-54.8-.3.5 4.4-11.4 7.1-18.8l3.6 17s10.3 47 12.4 56.6h-44.5zM232.2 152.9L180 283.6l-5.6-27c-9.7-31.2-39.9-65-73.7-81.9l47.9 173.8h56.6l84.2-195.6h-57.2"
fill="#1a1f71"
/>
<path
d="M131.9 152.9H46.3l-.7 3.8c67.1 16.2 111.5 55.4 129.9 102.5L157.2 169c-3.2-12.5-12.7-15.7-25.3-16.1"
fill="#f7a600"
/>
</svg>
) : (
<svg
className="h-5"
viewBox="0 0 780 500"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="312" cy="250" r="200" fill="#eb001b" />
<circle cx="468" cy="250" r="200" fill="#f79e1b" />
<path
d="M390 100.2c-49.7 38.3-81.6 98.1-81.6 165.8s31.9 127.5 81.6 165.8c49.7-38.3 81.6-98.1 81.6-165.8S439.7 138.5 390 100.2z"
fill="#ff5f00"
/>
</svg>
)}
</div>
<div>
<p className="font-medium">{type}</p>
<p className="text-sm text-ink-muted">PIN: {pin}</p>
</div>
</div>
<ApprovalButtons
onApprove={async () => {
await addNewCard({ type, color, pin } as NewCardRequest);
respond?.("Card created successfully");
}}
onDeny={() => respond?.("Card creation denied by user")}
/>
</div>
);
},
});
useHumanInTheLoop(
{
followUp: false,
name: "assignPolicyToCard",
description:
"Assign a policy to a card. Do NOT ask for confirmation - just call this action immediately. The approval UI will handle user confirmation.",
available: PERMISSIONS.ADD_POLICY.includes(currentUser.role),
parameters: z.object({
cardId: z
.string()
.describe("The card (from existing) to assign policy to"),
policyType: z.string().describe("The type of the policy to use"),
}),
render: ({ args, respond, status }) => {
const { cardId, policyType } = args;
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
const card = cards.find((c) => c.id === cardId);
const policy = policies.find((p) => p.type === policyType);
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">
Assign Policy to Card
</h3>
<div className="text-sm space-y-1">
<p>
<span className="text-ink-muted">Card:</span>{" "}
{card ? `${card.type} ending in ${card.last4}` : cardId}
</p>
<p>
<span className="text-ink-muted">Policy:</span> {policyType}
</p>
</div>
<ApprovalButtons
onApprove={async () => {
const policyId = policy?.id;
if (!cardId || !policyId) {
respond?.("Could not find matching policy to assign");
return;
}
await assignPolicyToCard({ cardId, policyId });
respond?.("Policy assigned successfully");
}}
onDeny={() => respond?.("Policy assignment denied by user")}
/>
</div>
);
},
// Re-register when cards/policies load; otherwise this render (mount-keyed
// effect) resolves the card/policy against the EMPTY initial arrays and
// shows raw ids. Mirrors selectCard / showTransactions.
},
[cards, policies],
);
// Visual card picker (human-in-the-loop). Instead of listing the user's
// cards as text, the agent calls this to render a tappable picker (brand +
// last 4 digits); the human's pick is returned to the agent so it can
// continue (e.g. then ask which policy and call assignPolicyToCard). Pure
// selection UI, so it's available to every role — the actual mutating action
// it precedes stays independently permission-gated.
useHumanInTheLoop(
{
followUp: false,
name: "selectCard",
description:
"Render a visual picker of the user's cards (brand + last 4 digits) and let them choose one. Call this whenever you need the user to choose which card to act on — for example before assigning a policy or changing a PIN. Do NOT list the cards as text; call this tool so the user can pick one directly. After the user picks, you receive the chosen card's id, type and last 4 digits.",
available: true,
parameters: z.object({
purpose: z
.string()
.describe(
"Short reason shown as the picker heading, e.g. 'Select a card to assign the Marketing policy'.",
)
.optional(),
}),
render: ({ args, respond, status }) => {
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
return (
<CardPicker
cards={cards}
policies={policies}
heading={args.purpose || "Select a card"}
onSelect={(card) =>
respond?.(
`User selected the ${card.type} card ending in ${card.last4} (cardId: ${card.id}).`,
)
}
/>
);
},
// Re-register the renderer once the cards/policies load; otherwise the
// render closure captures the initial EMPTY `cards` (registration runs in a
// mount effect keyed on these deps) and the picker shows "No cards
// available". Mirrors the deps array on the showTransactions useComponent.
},
[cards, policies],
);
useHumanInTheLoop(
{
followUp: false,
name: "addNoteToTransaction",
description:
"Add note to transaction. Do NOT ask for confirmation - just call this action immediately. The approval UI will handle user confirmation.",
available: PERMISSIONS.ADD_NOTE.includes(currentUser.role),
parameters: z.object({
transactionId: z
.string()
.describe("The transaction to add note to (ID provided by copilot)"),
content: z.string().describe("The content of the note"),
}),
render: ({ args, respond, status }) => {
const { transactionId, content } = args;
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
const transaction = transactions.find((t) => t.id === transactionId);
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">
Add Note to Transaction
</h3>
<div className="text-sm space-y-1">
<p>
<span className="text-ink-muted">Transaction:</span>{" "}
{transaction?.title ?? transactionId}
</p>
<p>
<span className="text-ink-muted">Note:</span> {content}
</p>
</div>
<ApprovalButtons
onApprove={async () => {
if (!transactionId || !content) {
respond?.("Missing transaction or note content");
return;
}
await addNoteToTransaction({ transactionId, content });
respond?.("Note added successfully");
}}
onDeny={() => respond?.("Note addition denied by user")}
/>
</div>
);
},
// Re-register when transactions load; otherwise this render (mount-keyed
// effect) resolves the transaction title against the EMPTY initial array.
// Mirrors selectCard / showTransactions.
},
[transactions],
);
// Showcase usage of generative UI. Display-only components use `useComponent`
// (not `useFrontendTool`): its render is unconditional, so the rendered card
// persists in the transcript after the tool call completes. A handler-driven
// `useFrontendTool` render only shows transiently while executing.
useComponent(
{
name: "showTransactions",
description:
"Displays a list of transactions upon request. At least one parameter is required per request",
parameters: z.object({
card4Digits: z
.string()
.describe("the last 4 digits of the card")
.optional(),
policyId: z
.string()
.describe("the id of the policy (figured out by copilot)")
.optional(),
transactionTitle: z
.string()
.describe("the title of the transaction")
.optional(),
}),
render: ({ card4Digits, policyId, transactionTitle }) => {
let filteredTransactions = transactions;
if (card4Digits) {
filteredTransactions = filterTransactionsByCardLast4(
transactions,
cards,
card4Digits,
);
} else if (policyId) {
filteredTransactions = filterTransactionsByPolicyId(
transactions,
policyId,
);
} else if (transactionTitle) {
filteredTransactions = filterTransactionByTitle(
transactions,
transactionTitle,
);
}
if (!filteredTransactions) {
return <>Problem fetching transactions</>;
}
return <TransactionsList transactions={filteredTransactions} compact />;
},
},
// Re-register the renderer when the underlying data loads/changes; otherwise
// the closure captures the initial empty `transactions`/`cards`.
[transactions, cards],
);
// Enable pin changing with co pilot
useHumanInTheLoop(
{
followUp: false,
name: "setCardPin",
description:
"Set the pin code of an existing card. Ask the user for the new 4-digit PIN, then call this action immediately. Do NOT ask for additional confirmation - the approval UI will handle that.",
available: PERMISSIONS.SET_PIN.includes(currentUser.role),
parameters: z.object({
cardId: z.string().describe("The id of the card (provided by copilot)"),
pin: z
.string()
.describe("The new 4-digit PIN code (provided by the user)"),
}),
render: ({ args, respond, status }) => {
const { cardId, pin } = args;
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
const card = cards.find((c) => c.id === cardId);
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">Change Card PIN</h3>
<div className="text-sm space-y-1">
<p>
<span className="text-ink-muted">Card:</span>{" "}
{card ? `${card.type} ending in ${card.last4}` : cardId}
</p>
<p>
<span className="text-ink-muted">New PIN:</span> {pin}
</p>
</div>
<ApprovalButtons
onApprove={async () => {
if (!pin || !cardId) {
respond?.("Missing PIN or card information");
return;
}
await changePin({ pin, cardId });
respond?.("PIN changed successfully");
}}
onDeny={() => respond?.("PIN change denied by user")}
/>
</div>
);
},
// Re-register when cards load; otherwise this render (mount-keyed effect)
// resolves the card against the EMPTY initial array. Mirrors selectCard /
// showTransactions.
},
[cards],
);
// Distractors. Plausible card/transaction actions that do NOT solve the
// over-limit block. No render — they run immediately and return a
// non-erroring success whose `note` makes clear it changed nothing about
// the transaction or policy. Their job is to widen the option space.
useFrontendTool({
name: "sendSpendAlert",
description: "Send a spend alert notification for a card.",
parameters: z.object({
cardId: z.string(),
message: z.string(),
}),
handler: async ({ cardId }) => ({
ok: true,
cardId,
note: "Spend alert sent. Informational only; does not modify any transaction or policy.",
}),
});
useFrontendTool({
name: "requestCardReplacement",
description: "Request a replacement card for an existing card.",
parameters: z.object({
cardId: z.string(),
}),
handler: async ({ cardId }) => ({
ok: true,
cardId,
note: "Replacement card requested. Does not affect pending transactions or policy limits.",
}),
});
useFrontendTool({
name: "flagForReview",
description: "Flag a transaction for manual review.",
parameters: z.object({
transactionId: z.string(),
reason: z.string(),
}),
handler: async ({ transactionId }) => ({
ok: true,
transactionId,
note: "Flagged for manual review. Does not approve or change the transaction.",
}),
});
if (!cards || !policies) return null;
return (
<div className="mx-auto max-w-7xl px-2 pb-4 md:px-4">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold tracking-tight text-ink">
Credit Cards
</h1>
<p className="text-sm text-ink-muted">
Manage cards and spending policies for your team.
</p>
</div>
<AddCardDropdown
handleAddCard={handleAddCard}
currentUser={currentUser}
/>
</div>
{/* Cards never squish: a horizontal-scroll row keeps each card at its
natural width (min 300px) and lets them grow to fill on wide screens.
When the chat panel docks open and narrows this area the row scrolls
horizontally instead of compressing the cards — which would otherwise
wrap the holder / valid-thru text. Mirrors the dashboard "My Cards".
`overflow-x-auto` also forces overflow-y to `auto`, so the matching
negative-margin / padding pairs (-mt-6/pt-6, -mx-2/px-2) keep the cards
in place while giving their soft drop shadows room inside the scroll
viewport instead of clipping them at the top/sides; pb-8 clears the
(downward) shadow at the bottom. */}
<div className="-mx-2 -mt-6 flex gap-6 overflow-x-auto px-2 pb-8 pt-6">
{cards.length ? (
cards.map((card) => (
<div key={card.id} className="min-w-[300px] max-w-[420px] flex-1">
<CreditCardDetails
card={card}
holder={currentUser.name}
policy={policies.find((p) => p.id === card.expensePolicyId)}
onChangePinModalOpen={() =>
dispatch({ dialogOpen: true, cardId: card.id })
}
/>
</div>
))
) : (
<div className="w-full rounded-2xl border border-dashed border-hairline bg-surface/60 p-10 text-center text-ink-muted">
No cards found for {currentUser.team} team
</div>
)}
</div>
<ChangePinDialog
dialogOpen={state.dialogOpen}
onSubmit={({ pin, cardId }) => handleChangePinSubmit({ pin, cardId })}
loading={state.loading}
onDialogOpenChange={(open) => dispatch({ dialogOpen: open })}
cards={cards}
/>
</div>
);
}
@@ -0,0 +1,107 @@
import type { ExpenseRole, Member, MemberRole } from "@/app/api/v1/data";
import { useEffect, useState } from "react";
export default function useTeam() {
const [team, setTeam] = useState<Member[]>([]);
const fetchUsers = async () => {
try {
const response = await fetch("/api/v1/users");
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
setTeam(data);
} catch (error) {
console.error("There was a problem with the fetch operation:", error);
}
};
const removeMember = async (id: string) => {
try {
const response = await fetch(`/api/v1/users/${id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
} catch (error) {
console.error("There was a problem with the fetch operation:", error);
}
};
const inviteMember = async (
email: string,
role: MemberRole,
team: ExpenseRole,
) => {
try {
const response = await fetch(`/api/v1/users`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, role, team }),
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
await response.json();
void fetchUsers();
} catch (error) {
console.error("There was a problem with the fetch operation:", error);
}
};
const changeMemberRole = async (id: string, role: MemberRole) => {
try {
const response = await fetch(`/api/v1/users/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ role }),
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
await response.json();
void fetchUsers();
} catch (error) {
console.error("There was a problem with the fetch operation:", error);
}
};
const changeMemberTeam = async (id: string, team: ExpenseRole) => {
try {
const response = await fetch(`/api/v1/users/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ team }),
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
await response.json();
void fetchUsers();
} catch (error) {
console.error("There was a problem with the fetch operation:", error);
}
};
useEffect(() => {
void (async () => {
await fetchUsers();
})();
}, []);
return {
team,
inviteMember,
removeMember,
changeMemberRole,
changeMemberTeam,
};
}
@@ -0,0 +1,310 @@
"use client";
import useTeam from "@/app/team/actions";
import { Button } from "@/components/ui/button";
import { UserPlus } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAuthContext } from "@/components/auth-context";
import type { ExpenseRole } from "@/app/api/v1/data";
import { MemberRole } from "@/app/api/v1/data";
import { useEffect, useReducer } from "react";
import { TeamPageOperations } from "@/components/copilot-context";
import { useSearchParams } from "next/navigation";
import { useAgentContext, useHumanInTheLoop } from "@copilotkit/react-core/v2";
import { z } from "zod";
import type { DialogState } from "@/components/add-or-edit-member-dialog";
import {
AddOrEditMemberDialog,
defaultDialogState,
} from "@/components/add-or-edit-member-dialog";
import { RemoveMemberConfirmationDialog } from "@/components/remove-member-dialog";
import { ApprovalButtons } from "@/components/approval-buttons";
const dialogStateReducer = (
state: DialogState,
payload: Partial<DialogState>,
): DialogState => ({
...state,
...payload,
});
export default function Team() {
const { currentUser } = useAuthContext();
const {
team,
inviteMember,
removeMember,
changeMemberRole,
changeMemberTeam,
} = useTeam();
const searchParams = useSearchParams();
const operation = searchParams.get("operation") as TeamPageOperations | null;
useAgentContext({
description: "The available users of the system.",
value: JSON.stringify(team),
});
useHumanInTheLoop({
followUp: false,
name: "removeMember",
description:
"Remove a team member. Do NOT ask for confirmation - just call this action immediately. The approval UI will handle user confirmation.",
parameters: z.object({
id: z
.string()
.describe(
"The ID of the member to remove (provided by copilot, ask questions to figure out the member)",
),
}),
render: ({ args, respond, status }) => {
const { id } = args;
if (status === "inProgress")
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
const member = team.find((m) => m.id === id);
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="font-semibold text-lg">Remove Team Member</h3>
<div className="text-sm space-y-1">
<p>
<span className="text-ink-muted">Member:</span>{" "}
{member?.name ?? id}
</p>
<p>
<span className="text-ink-muted">Role:</span> {member?.role}
</p>
</div>
<ApprovalButtons
onApprove={async () => {
if (!id) {
respond?.("Missing member information");
return;
}
await removeMember(id);
respond?.("Member removed successfully");
}}
onDeny={() => respond?.("Member removal denied by user")}
/>
</div>
);
},
});
useHumanInTheLoop({
followUp: false,
name: "changeMemberRole",
description:
"Change the role of a team member. Do NOT ask for confirmation - just call this action immediately. The approval UI will handle user confirmation.",
parameters: z.object({
id: z.string().describe("The ID of the member to change the role of"),
role: z.string().describe("The new role of the member"),
}),
render: ({ args, respond, status }) => {
const { id, role } = args;
if (status === "inProgress")
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
const member = team.find((m) => m.id === id);
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="font-semibold text-lg">Change Member Role</h3>
<div className="text-sm space-y-1">
<p>
<span className="text-ink-muted">Member:</span>{" "}
{member?.name ?? id}
</p>
<p>
<span className="text-ink-muted">Current Role:</span>{" "}
{member?.role}
</p>
<p>
<span className="text-ink-muted">New Role:</span> {role}
</p>
</div>
<ApprovalButtons
onApprove={async () => {
if (!id || !role) {
respond?.("Missing member or role information");
return;
}
await changeMemberRole(id, role as MemberRole);
respond?.("Role changed successfully");
}}
onDeny={() => respond?.("Role change denied by user")}
/>
</div>
);
},
});
useHumanInTheLoop({
followUp: false,
name: "changeMemberTeam",
description:
"Change the team of a team member. Do NOT ask for confirmation - just call this action immediately. The approval UI will handle user confirmation.",
parameters: z.object({
id: z.string().describe("The ID of the member to change the team of"),
team: z.string().describe("The new team of the member"),
}),
render: ({ args, respond, status }) => {
const { id, team: newTeam } = args;
if (status === "inProgress")
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
const member = team.find((m) => m.id === id);
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="font-semibold text-lg">Change Member Team</h3>
<div className="text-sm space-y-1">
<p>
<span className="text-ink-muted">Member:</span>{" "}
{member?.name ?? id}
</p>
<p>
<span className="text-ink-muted">Current Team:</span>{" "}
{member?.team}
</p>
<p>
<span className="text-ink-muted">New Team:</span> {newTeam}
</p>
</div>
<ApprovalButtons
onApprove={async () => {
if (!id || !newTeam) {
respond?.("Missing member or team information");
return;
}
await changeMemberTeam(id, newTeam as ExpenseRole);
respond?.("Team changed successfully");
}}
onDeny={() => respond?.("Team change denied by user")}
/>
</div>
);
},
});
const [dialogState, dispatchDialogState] = useReducer(
dialogStateReducer,
defaultDialogState,
);
const handleAddMemberSubmit = () => {
dispatchDialogState({ loading: true });
void inviteMember(dialogState.email, dialogState.role, dialogState.team);
dispatchDialogState({ dialogOpen: false, loading: false });
};
useEffect(() => {
const operationNameToMethod: Partial<
Record<TeamPageOperations, () => void>
> = {
[TeamPageOperations.InviteMember]: () =>
dispatchDialogState({ dialogOpen: true }),
};
if (!operation || !Object.values(TeamPageOperations).includes(operation))
return;
operationNameToMethod[operation]?.();
}, [operation]);
return (
<div className="mx-auto max-w-7xl space-y-6 px-2 pb-4 md:px-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold tracking-tight text-ink">
Team Management
</h2>
<p className="text-sm text-ink-muted">
Invite teammates and manage roles &amp; departments.
</p>
</div>
<Button
onClick={() =>
dispatchDialogState({ dialogOpen: true, action: "add" })
}
>
<UserPlus className="mr-2 h-4 w-4" /> Invite Team Member
</Button>
</div>
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{team.map((member) => (
<Card key={member.id} className="p-2">
<CardHeader>
<CardTitle className="text-ink">{member.name}</CardTitle>
<CardDescription>{member.email}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm">
<span className="text-ink-muted">Role</span>
<span className="inline-flex items-center rounded-full bg-brand-soft px-2.5 py-0.5 text-xs font-semibold text-brand-indigo dark:text-brand-violet">
{member.role}
</span>
</div>
</CardContent>
{currentUser.role === MemberRole.Admin ? (
<CardFooter className="flex justify-between">
<Button
variant="outline"
onClick={() =>
dispatchDialogState({
memberId: member.id,
dialogOpen: true,
action: "edit",
})
}
>
Edit
</Button>
<Button
variant="destructive"
onClick={() =>
dispatchDialogState({
memberId: member.id,
dialogOpen: true,
action: "remove",
})
}
>
Remove
</Button>
</CardFooter>
) : null}
</Card>
))}
</div>
<AddOrEditMemberDialog
dialogState={dialogState}
onStateChange={dispatchDialogState}
onSubmit={handleAddMemberSubmit}
/>
{dialogState.action === "remove" && (
<RemoveMemberConfirmationDialog
dialogState={dialogState}
onStateChange={dispatchDialogState}
onSubmit={async () => {
await removeMember(dialogState.memberId!);
dispatchDialogState(defaultDialogState);
}}
members={team}
/>
)}
</div>
);
}
@@ -0,0 +1,327 @@
"use client";
import { LayoutComponent } from "@/components/layout";
import {
CopilotChatConfigurationProvider,
CopilotKitProvider,
useConfigureSuggestions,
type ReactActivityMessageRenderer,
} from "@copilotkit/react-core/v2";
import { z } from "zod";
import { catalog } from "@/a2ui/catalog";
import { CanvasProvider } from "@/components/canvas/canvas-context";
import CopilotContext from "@/components/copilot-context";
import { useAuthContext } from "@/components/auth-context";
import { useThreadSelection } from "@/components/threads/use-thread-selection";
import { ChatInboxProvider } from "@/components/chat/chat-inbox-context";
import { ChatPanel } from "@/components/chat/chat-panel";
import { RecordingProvider } from "@/components/recording-context";
import { RecordingVignette } from "@/components/recording-vignette";
import { ProactiveNotice } from "@/components/wow/proactive-notice";
import { ReportCopilotTools } from "@/components/wow/report-tool";
import { GlassEngineProvider } from "@/components/glass-engine-context";
import { InspectorStoreProvider } from "@/lib/inspector/store";
import { InspectorPane } from "@/components/inspector/inspector-pane";
import { sandboxFunctions } from "@/opengen/sandbox-functions";
import { SandboxDataSync } from "@/opengen/sandbox-data-sync";
// The agent's render_report tool result becomes an `a2ui-surface` activity that
// <ReportCanvas/> renders full-screen (it reads the ops from the agent message
// stream). In the chat we leave only a small handoff pill in place of the
// built-in inline surface renderer. Module-level so the array reference stays
// stable across renders (CopilotKitProvider requires a stable
// `renderActivityMessages` array).
function ReportHandoffPill() {
return (
<div className="my-1.5 inline-flex max-w-fit items-center gap-2 rounded-full border border-hairline bg-surface px-3 py-2 text-xs font-medium text-ink">
<span className="h-2 w-2 rounded-full bg-brand" />
<span className="uppercase tracking-wide text-ink-muted">report</span>
<span aria-hidden className="text-ink-muted">
</span>
<span>rendered on the canvas</span>
</div>
);
}
// Same handoff treatment for Open Generative UI: the sandboxed iframe renders
// full-region on the canvas (see <ReportCanvas/>), so the chat shows only this
// pill. Registering it OVERRIDES the built-in inline OGUI renderer (user-provided
// renderers take precedence).
function OguiHandoffPill() {
return (
<div className="my-1.5 inline-flex max-w-fit items-center gap-2 rounded-full border border-hairline bg-surface px-3 py-2 text-xs font-medium text-ink">
<span className="h-2 w-2 rounded-full bg-brand" />
<span className="uppercase tracking-wide text-ink-muted">
interactive
</span>
<span aria-hidden className="text-ink-muted">
</span>
<span>rendered on the canvas</span>
</div>
);
}
const A2UI_RENDERERS: ReactActivityMessageRenderer<unknown>[] = [
{ activityType: "a2ui-surface", content: z.any(), render: ReportHandoffPill },
{
activityType: "open-generative-ui",
content: z.any(),
render: OguiHandoffPill,
},
];
// Static suggestion pills — the demo's full use-case catalog, available at
// ALL times (`available: "always"`), not just the welcome screen: the demo
// must stay fully click-drivable after every exchange, with zero typing.
// In v2, suggestions are registered via `useConfigureSuggestions` rather than a
// prop on the chat component (the v1 `suggestions` prop does not exist on the
// v2 component — it's omitted from `CopilotChatProps` and supplied via the hook
// instead). See packages/react-core/src/v2/hooks/use-configure-suggestions.tsx
// and packages/react-core/src/v2/components/chat/CopilotChat.tsx (line 4146).
//
// The first three pills are sequenced to drive the self-learning demo arc
// (FOR-137). The agent prompt (api/copilotkit/[[...slug]]/route.ts) deliberately
// never explains the over-policy-limit unlock, and the agent only ever sees
// exception *codes*, never their human labels — so a fresh agent must LEARN,
// by watching an officer file a justifying exception, both that over-limit
// charges can be unlocked and which codes justify it.
// 1. Teach-ask — hits the gate (agent correctly stalls before learning).
// 2. Teach-setup — surfaces the pending charges so the officer can
// demonstrate the unlock inline (that demonstration is what gets recorded).
// 3. Recall — on a FRESH thread after distill, the agent applies the
// learned procedure to a DIFFERENT over-limit charge it was never taught.
// Titles stay symptom-only: they must NOT hint at the exception path the agent
// is meant to learn on its own.
//
// The remaining pills cover every other use case the demo ships: gen-UI
// charts, the approvals explainer, report artifacts, and the cross-page
// operations (PIN change, team invite) that ride navigateToPageAndPerform.
// Page-scoped fire-and-forget tools (spend alert, card replacement, flag for
// review) are deliberately NOT pilled: they only exist on the home route, so
// a global pill for them would be a broken promise on every other page.
// Design brief handed to generateSandboxedUi so generated UIs match the demo's
// look instead of the generic DEFAULT_DESIGN_SKILL. Dark-mode aware, brand accent,
// glass surfaces, Geist type — the same language as the curated cards.
const NORTHWIND_DESIGN_SKILL = `You are designing UI for Northwind Finance, a
corporate banking dashboard. Match its aesthetic:
- Surfaces: rounded-2xl cards, subtle hairline borders, soft shadow, a translucent
"glass" surface over the page background. Generous padding.
- Type: the Geist sans-serif family; clear hierarchy (semibold headings, muted
secondary text). Currency in USD with thousands separators.
- Color: a restrained neutral base with a single indigo/violet brand accent for
emphasis, positive = green, negative/over-limit = red. Never rainbow palettes.
- Dark-mode aware: read colors from CSS variables / prefers-color-scheme; never
hardcode white backgrounds.
- Keep it calm, precise, and enterprise-appropriate — this is a finance tool.`;
function BankingSuggestions() {
useConfigureSuggestions({
available: "always",
suggestions: [
{
// BEAT 1 — the teachable ask. Seed t-1: Google Ads, -$5,000, Marketing
// policy (limit $5,000 / spent $500 → approving always trips
// OVER_POLICY_LIMIT). Pre-learning the agent stalls here correctly;
// post-learning the same ask succeeds. Leads the welcome screen.
title: "Approve the $5,000 Google Ads charge",
message:
"Approve the $5,000 Google Ads charge on the Marketing policy.",
},
{
// BEAT 2 setup — surfaces the pending charges (all three are over their
// policy limit) via showTransactions, the unconditional-render tool
// that is reliable in every mode. The officer files a policy exception
// inline on a row to demonstrate the unlock; that is the recorded
// demonstration the writer agent distills into /knowledge.
title: "Review my pending transactions",
message: "Show me my pending transactions.",
},
{
// BEAT 3 — recall / generalization. On a fresh thread after the
// demonstration is distilled, the agent should apply the learned
// procedure to a DIFFERENT over-limit charge it was never explicitly
// taught (seed t-2: AWS, -$15,000, Engineering policy, limit $15,000 /
// spent $1,500), proving transferable memory, not per-row memorization.
title: "How should I handle the $15,000 AWS charge?",
message:
"The $15,000 AWS charge is over its policy limit. How should I handle it?",
},
{
// Breadth — a clean non-arc capability (the generative-UI card flow).
title: "Add an expense card",
message: "Add a new expense card",
},
// ── A2UI report canvas ───────────────────────────────────────────────
{
title: "Build a spend report on the canvas",
message:
"Build a full spend report on the canvas: KPIs, the spending trend, budget usage, and a spend breakdown by team.",
},
// ── Gen-UI charts ────────────────────────────────────────────────────
{
title: "Show the spending trend",
message: "Show me the spending trend over time.",
},
{
title: "Budgets near their limit?",
message:
"Show me budget usage by policy — which ones are close to or over their limit?",
},
{
title: "Where is the money going?",
message: "Where is the money going? Break down spend by team.",
},
{
title: "How's our cash flow?",
message: "Compare our income vs expenses — how is our cash flow?",
},
// ── Open Generative UI (custom interactive surfaces) ─────────────────
{
// OGUI-only: an interactive explorer the fixed catalog can't express.
// "interactive"/"explorer" (not "build") is what routes this to
// generateSandboxedUi; figures come from the sandbox functions.
title: "Build an interactive spend explorer",
message:
"Build an interactive spend explorer I can filter and play with — pull the real transactions and policies.",
},
{
title: "Prototype a cash-flow what-if calculator",
message:
"Prototype an interactive what-if calculator for our cash flow using our real income and expense figures.",
},
{
title: "How do approvals work?",
message: "Explain how an over-limit charge gets cleared and approved.",
},
// ── Work product ─────────────────────────────────────────────────────
{
title: "Prep the Q2 spend report",
message:
"Prepare a Q2 spend report for the board: summarize spend against budgets, call out anything over limit or pending, and file it as a report.",
},
// ── Cross-page operations (navigateToPageAndPerform fallbacks) ──────
{
title: "Change my card PIN",
message: "I want to change the PIN on my Visa card.",
},
{
title: "Invite a team member",
message: "Invite a new member to my team.",
},
],
});
return null;
}
export function CopilotKitWrapper({
children,
glassAvailable = false,
resetEnabled = false,
}: {
children: React.ReactNode;
glassAvailable?: boolean;
resetEnabled?: boolean;
}) {
const { currentUser } = useAuthContext();
const { threadId, selectThread, createThread } = useThreadSelection();
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit"
// The runtime route is the multi-endpoint REST handler
// (createCopilotHonoHandler at api/copilotkit/[[...slug]]: /info,
// /agent/{id}/run, /agent/{id}/connect). The single-endpoint transport
// POSTs to /api/copilotkit and 404s against this handler, so stay in REST
// (multi-route) mode.
useSingleEndpoint={false}
properties={{ userRole: currentUser?.role, userId: currentUser?.id }}
// A2UI report canvas. The agent calls the backend render_report tool,
// whose ops the A2UI middleware turns into an `a2ui-surface` activity; the
// banking catalog here lets the client render those ops. The agent selects
// widgets via render_report's typed params, so it does NOT need the raw
// A2UI component schema (no includeSchema). `renderActivityMessages`
// replaces the built-in inline surface renderer with a small handoff pill —
// the surface itself renders full-screen in <ReportCanvas/>.
a2ui={{ catalog }}
renderActivityMessages={A2UI_RENDERERS}
openGenerativeUI={{
sandboxFunctions,
designSkill: NORTHWIND_DESIGN_SKILL,
}}
// Use the v2-native CopilotKitProvider, NOT the v1 `CopilotKit`
// compatibility bridge. The bridge wraps the chat in a heavier stack (its
// own ThreadsProvider + a second CopilotChatConfigurationProvider +
// listeners); that extra churn re-fires CopilotChat's /connect effect
// cleanup mid-run, and the cleanup calls AG-UI `detachActiveRun()` — which
// silently tears down the in-flight run the instant the agent emits a
// frontend (HITL) tool call, so the tool's render() never mounts and the
// chat appears "stuck". CopilotKitProvider is the lean stack the working
// e-commerce reference uses; our inbox's `useThreads` (from /v2) reads
// CopilotKitProvider's own context, so the inbox keeps working.
showDevConsole={false}
>
{/*
Anchor the whole chat surface to the actively-selected thread. The
agentId is "default" — the runtime registers `agents: { default: ... }`
and the SDK's default agentId is also "default" (NOT "bankingAgent").
We pass `threadId` and let the provider infer explicit-thread mode
(CopilotChatConfigurationProvider treats a supplied threadId as
explicit), matching the working e-commerce reference. This outer
provider also stays in sync with the docked panel's open/close state
(the sidebar's modal state propagates upward), which the inbox overlay
reads to know when the panel is showing.
*/}
<CopilotChatConfigurationProvider agentId="default" threadId={threadId}>
<BankingSuggestions />
<SandboxDataSync />
{/*
ChatInboxProvider carries the inbox open/closed state + the thread
actions (select/create) so the panel header and the inbox rows can
switch or start conversations and collapse the inbox in one place.
The docked panel starts CLOSED for a clean first impression; a
floating toggle button (bottom-right) opens it.
*/}
<ChatInboxProvider
selectedThreadId={threadId}
onSelectThread={selectThread}
onCreateThread={createThread}
>
{/*
RecordingProvider exposes the teach-mode `isRecording` flag; the
<RecordingVignette/> reads it to pulse a soft violet glow around the
canvas while an officer demonstration is being recorded. It wraps
BOTH the page content and the chat panel so every demonstration
call site (the transactions list approve/deny, the inline policy
exception card) is inside it.
*/}
<GlassEngineProvider available={glassAvailable}>
<InspectorStoreProvider>
<RecordingProvider>
{/*
CanvasProvider derives whether a report surface is active from
the agent message stream (+ a local dismiss for the "← Back"
control). It must be an ancestor of LayoutComponent, which calls
useCanvas() to render <ReportCanvas/> in place of the page body.
*/}
<CanvasProvider>
<LayoutComponent resetEnabled={resetEnabled}>
<CopilotContext>{children}</CopilotContext>
</LayoutComponent>
<ChatPanel threadId={threadId} />
<ProactiveNotice />
<ReportCopilotTools />
</CanvasProvider>
{/* Mount the pane (and its AG-UI subscription) ONLY where the
deployment opted in. Public hosts never subscribe. */}
{glassAvailable && <InspectorPane />}
<RecordingVignette />
</RecordingProvider>
</InspectorStoreProvider>
</GlassEngineProvider>
</ChatInboxProvider>
</CopilotChatConfigurationProvider>
</CopilotKitProvider>
);
}
@@ -0,0 +1,106 @@
import { Button } from "@/components/ui/button";
import { ChevronDown, Plus } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CardBrand, MemberRole } from "@/app/api/v1/data";
import { PERMISSIONS } from "@/app/api/v1/permissions";
interface AddCardDropdownProps {
currentUser: {
role: MemberRole;
};
handleAddCard: (params: { type: CardBrand }) => void;
}
export function AddCardDropdown({
currentUser,
handleAddCard,
}: AddCardDropdownProps) {
const hasPermission = PERMISSIONS.ADD_CARD.includes(currentUser.role);
const dropdownButton = (
<Button disabled={!hasPermission}>
<Plus className="mr-2 h-4 w-4" /> Add New Card{" "}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
{hasPermission ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{dropdownButton}
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => handleAddCard({ type: CardBrand.Visa })}
>
<div className="flex items-center">
<svg
className="h-5 w-8 mr-2"
viewBox="0 0 780 500"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M293.2 348.7l33.4-195.8h53.4l-33.4 195.8zM540.7 157.2c-10.6-4-27.2-8.3-47.9-8.3-52.8 0-90 26.6-90.2 64.6-.3 28.1 26.5 43.8 46.8 53.2 20.8 9.6 27.8 15.7 27.7 24.3-.1 13.1-16.6 19.1-32 19.1-21.4 0-32.7-3-50.3-10.2l-6.9-3.1-7.5 43.8c12.5 5.5 35.6 10.2 59.6 10.5 56.2 0 92.6-26.3 93-66.8.2-22.3-14-39.2-44.8-53.2-18.6-9.1-30.1-15.1-30-24.3 0-8.1 9.7-16.8 30.6-16.8 17.4-.3 30.1 3.5 39.9 7.5l4.8 2.3 7.2-42.7zM676.3 152.9h-41.3c-12.8 0-22.4 3.5-28 16.3l-79.4 179.5h56.2s9.2-24.2 11.3-29.5c6.1 0 60.8.1 68.6.1 1.6 6.9 6.5 29.4 6.5 29.4h49.7l-43.6-195.8zm-65.8 126.3c4.4-11.3 21.4-54.8 21.4-54.8-.3.5 4.4-11.4 7.1-18.8l3.6 17s10.3 47 12.4 56.6h-44.5zM232.2 152.9L180 283.6l-5.6-27c-9.7-31.2-39.9-65-73.7-81.9l47.9 173.8h56.6l84.2-195.6h-57.2"
fill="#1a1f71"
/>
<path
d="M131.9 152.9H46.3l-.7 3.8c67.1 16.2 111.5 55.4 129.9 102.5L157.2 169c-3.2-12.5-12.7-15.7-25.3-16.1"
fill="#f7a600"
/>
</svg>
Add Visa Card
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleAddCard({ type: CardBrand.MasterCard })
}
>
<div className="flex items-center">
<svg
className="h-5 w-8 mr-2"
viewBox="0 0 780 500"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="312" cy="250" r="200" fill="#eb001b" />
<circle cx="468" cy="250" r="200" fill="#f79e1b" />
<path
d="M390 100.2c-49.7 38.3-81.6 98.1-81.6 165.8s31.9 127.5 81.6 165.8c49.7-38.3 81.6-98.1 81.6-165.8S439.7 138.5 390 100.2z"
fill="#ff5f00"
/>
</svg>
Add Mastercard
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
dropdownButton
)}
</div>
</TooltipTrigger>
{!hasPermission && (
<TooltipContent>
<p>Only admins can add new cards</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,151 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ExpenseRole, MemberRole } from "@/app/api/v1/data";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
export interface DialogState {
email: string;
role: MemberRole;
team: ExpenseRole;
loading: boolean;
dialogOpen: boolean;
memberId: string | null;
action: "add" | "edit" | "remove";
}
export const defaultDialogState = {
email: "",
role: MemberRole.Member,
team: ExpenseRole.Marketing,
loading: false,
dialogOpen: false,
memberId: null,
action: "add" as DialogState["action"],
};
export interface MemberDialogProps {
onStateChange: (payload: Partial<DialogState>) => void;
onSubmit: () => void;
dialogState: DialogState;
}
export function AddOrEditMemberDialog({
onStateChange,
onSubmit,
dialogState,
}: MemberDialogProps) {
const isEdit = dialogState.action === "edit";
return (
<Dialog
open={
dialogState.dialogOpen &&
(isEdit ? !!dialogState.memberId : !dialogState.memberId)
}
onOpenChange={(open) =>
onStateChange(open ? { dialogOpen: open } : defaultDialogState)
}
>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Member" : "Invite Member"}</DialogTitle>
<DialogDescription>
{isEdit
? "Edit the team or role of a member"
: "Enter the email, role, and team for the new member."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{!isEdit && (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="email" className="text-right">
Email
</Label>
<Input
id="email"
type="email"
value={dialogState.email}
onChange={(e) => onStateChange({ email: e.target.value })}
className="col-span-3"
/>
</div>
)}
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="role" className="text-right">
Role
</Label>
<Select
onValueChange={(value) =>
onStateChange({ role: value as MemberRole })
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={MemberRole.Admin}>Admin</SelectItem>
<SelectItem value={MemberRole.Member}>Member</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="team" className="text-right">
Team
</Label>
<Select
onValueChange={(value) =>
onStateChange({ team: value as ExpenseRole })
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a team" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={ExpenseRole.Marketing}>
Marketing
</SelectItem>
<SelectItem value={ExpenseRole.Engineering}>
Engineering
</SelectItem>
<SelectItem value={ExpenseRole.Executive}>
Executive
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="submit" onClick={() => onSubmit()}>
{dialogState.loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : isEdit ? (
"Edit Member"
) : (
"Invite Member"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,290 @@
"use client";
import { useMemo } from "react";
import type { ExpensePolicy, Transaction } from "@/app/api/v1/data";
import { cn, formatCurrency } from "@/lib/utils";
import { StatisticsChart } from "@/components/statistics-chart";
// Brand-leading palette for multi-series charts (violet → indigo → supporting
// hues). Hand-picked to sit on the lavender surface and read distinctly in the
// donut legend. No charting dependency — every chart here is plain SVG/CSS,
// matching StatisticsChart's "lightweight, hand-rolled" approach.
const PALETTE = [
"hsl(252 83% 67%)", // brand violet
"hsl(248 84% 60%)", // brand indigo
"hsl(199 89% 56%)", // sky
"hsl(160 70% 45%)", // emerald
"hsl(38 92% 55%)", // amber
"hsl(330 75% 60%)", // pink
];
/**
* Spend-over-time trend. Buckets expenses by calendar month (oldest → newest)
* and feeds StatisticsChart, falling back to representative seeded points when
* there isn't enough history — mirrors the dashboard's Statistics rail so the
* chat trend and the dashboard trend tell the same story.
*/
export function SpendingTrendChart({
transactions,
}: {
transactions: Transaction[];
}) {
const { stats, labels } = useMemo(() => {
const byMonth = new Map<string, number>();
for (const t of transactions) {
if (t.amount >= 0) continue;
const d = new Date(t.date);
if (Number.isNaN(d.getTime())) continue;
byMonth.set(
`${d.getFullYear()}-${d.getMonth()}`,
(byMonth.get(`${d.getFullYear()}-${d.getMonth()}`) ?? 0) +
Math.abs(t.amount),
);
}
const monthFmt = new Intl.DateTimeFormat("en-US", { month: "short" });
const sorted = [...byMonth.entries()].sort(([a], [b]) => {
const [ay, am] = a.split("-").map(Number);
const [by, bm] = b.split("-").map(Number);
return ay - by || am - bm;
});
if (sorted.length >= 3) {
return {
stats: sorted.map(([, v]) => v),
labels: sorted.map(([k]) => {
const [y, m] = k.split("-").map(Number);
return monthFmt.format(new Date(y, m, 1));
}),
};
}
return {
stats: [3200, 4100, 3600, 5200, 4800, 6400],
labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
};
}, [transactions]);
return <StatisticsChart data={stats} labels={labels} />;
}
/**
* Budget usage per expense policy — a horizontal bar of `spent / limit` for
* each team's policy. Bars use the brand gradient; a policy already past its
* limit turns red and calls out the overage. This is "how's our budget?" at a
* glance.
*/
export function BudgetUsageChart({ policies }: { policies: ExpensePolicy[] }) {
if (!policies.length) {
return (
<p className="text-sm text-ink-muted">No expense policies to show.</p>
);
}
return (
<div className="space-y-3.5">
{policies.map((policy) => {
const pct = policy.limit > 0 ? (policy.spent / policy.limit) * 100 : 0;
const over = policy.spent > policy.limit;
return (
<div key={policy.id} className="space-y-1">
<div className="flex items-baseline justify-between text-sm">
<span className="font-medium text-ink">{policy.type}</span>
<span className="tabular-nums text-ink-muted">
{formatCurrency(policy.spent)}
<span className="text-ink-muted/60">
{" "}
/ {formatCurrency(policy.limit)}
</span>
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-surface-muted">
<div
className={cn(
"h-full rounded-full",
over ? "bg-negative" : "brand-gradient",
)}
style={{ width: `${Math.min(100, Math.max(2, pct))}%` }}
/>
</div>
{over && (
<p className="text-xs font-medium text-negative">
Over limit by {formatCurrency(policy.spent - policy.limit)}
</p>
)}
</div>
);
})}
</div>
);
}
/**
* Spend breakdown — a donut of each policy's spend as a share of total, with a
* legend. Built with stroke-dasharray segments on a rotated circle (no arc
* math, no dependency); the total sits in the center hole.
*/
export function SpendBreakdownChart({
policies,
}: {
policies: ExpensePolicy[];
}) {
const segments = policies
.filter((p) => p.spent > 0)
.map((p, i) => ({
label: p.type,
value: p.spent,
color: PALETTE[i % PALETTE.length],
}));
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (!total) {
return (
<p className="text-sm text-ink-muted">No spend to break down yet.</p>
);
}
const radius = 42;
const circumference = 2 * Math.PI * radius;
// Precompute each arc's length and its start offset (cumulative length of the
// preceding arcs) without a mutable accumulator, so the render stays pure.
const arcs = segments.map((s, i) => ({
...s,
len: (s.value / total) * circumference,
offset: segments
.slice(0, i)
.reduce((sum, x) => sum + (x.value / total) * circumference, 0),
}));
return (
<div className="flex items-center gap-5">
<div className="relative h-32 w-32 flex-none">
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="hsl(var(--hairline))"
strokeWidth="12"
/>
{arcs.map((s) => (
<circle
key={s.label}
cx="50"
cy="50"
r={radius}
fill="none"
stroke={s.color}
strokeWidth="12"
strokeDasharray={`${s.len} ${circumference - s.len}`}
strokeDashoffset={-s.offset}
strokeLinecap="butt"
/>
))}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[0.65rem] uppercase tracking-wide text-ink-muted">
Total
</span>
<span className="text-sm font-semibold tabular-nums text-ink">
{formatCurrency(total)}
</span>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-1.5 text-sm">
{segments.map((s) => (
<li key={s.label} className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 flex-none rounded-full"
style={{ background: s.color }}
/>
<span className="truncate text-ink">{s.label}</span>
<span className="ml-auto tabular-nums text-ink-muted">
{Math.round((s.value / total) * 100)}%
</span>
</li>
))}
</ul>
</div>
);
}
// A single labelled meter bar (label · value · proportional fill). Module-level
// so it isn't re-created on every IncomeExpenseChart render.
function MeterRow({
label,
value,
max,
fill,
text,
}: {
label: string;
value: number;
max: number;
fill: string;
text: string;
}) {
return (
<div className="space-y-1">
<div className="flex items-baseline justify-between text-sm">
<span className="text-ink-muted">{label}</span>
<span className={cn("font-semibold tabular-nums", text)}>
{formatCurrency(value)}
</span>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-surface-muted">
<div
className={cn("h-full rounded-full", fill)}
style={{ width: `${Math.max(2, (value / max) * 100)}%` }}
/>
</div>
</div>
);
}
/**
* Income vs expenses — two proportional bars (incoming green, outgoing red)
* plus the net. Summed straight from the transaction amounts.
*/
export function IncomeExpenseChart({
transactions,
}: {
transactions: Transaction[];
}) {
const income = transactions
.filter((t) => t.amount > 0)
.reduce((sum, t) => sum + t.amount, 0);
const expenses = transactions
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
const max = Math.max(income, expenses, 1);
const net = income - expenses;
return (
<div className="space-y-3.5">
<MeterRow
label="Income"
value={income}
max={max}
fill="bg-positive"
text="text-positive"
/>
<MeterRow
label="Expenses"
value={expenses}
max={max}
fill="bg-negative"
text="text-negative"
/>
<div className="flex items-baseline justify-between border-t border-hairline pt-2.5 text-sm">
<span className="font-medium text-ink">Net</span>
<span
className={cn(
"font-semibold tabular-nums",
net >= 0 ? "text-positive" : "text-negative",
)}
>
{net >= 0 ? "+" : ""}
{formatCurrency(Math.abs(net))}
</span>
</div>
</div>
);
}
@@ -0,0 +1,46 @@
"use client";
import { useState } from "react";
export function ApprovalButtons({
onApprove,
onDeny,
approveLabel = "Approve",
denyLabel = "Deny",
}: {
onApprove: () => Promise<void> | void;
onDeny: () => void;
approveLabel?: string;
denyLabel?: string;
}) {
const [responded, setResponded] = useState(false);
if (responded) {
return <p className="text-sm italic text-ink-muted">Response submitted.</p>;
}
return (
<div className="flex gap-2">
<button
className="brand-gradient flex-1 rounded-full px-4 py-2 text-sm font-medium text-white shadow-[0_6px_16px_hsl(252_83%_60%/0.3)] transition-all hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
onClick={async () => {
setResponded(true);
await onApprove();
}}
>
{approveLabel}
</button>
<button
className="flex-1 rounded-full bg-surface-muted px-4 py-2 text-sm font-medium text-ink transition-colors hover:bg-brand-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
onClick={() => {
setResponded(true);
onDeny();
}}
>
{denyLabel}
</button>
</div>
);
}
export default ApprovalButtons;
@@ -0,0 +1,73 @@
"use client";
import { AlertTriangle, Check, FileText, ShieldCheck } from "lucide-react";
// The four beats of clearing an over-limit charge, as a vertical stepper. This
// is the explainer twin of the teach/recall loop: it shows the user (or the
// agent, when asked "how does this work?") the same open → finalize → approve
// procedure the copilot performs, without naming a specific exception code.
const STEPS = [
{
icon: AlertTriangle,
title: "Over-limit charge",
desc: "The charge exceeds its policy limit, so it can't be approved directly.",
tone: "text-negative bg-negative-soft",
},
{
icon: FileText,
title: "File a policy exception",
desc: "Open an exception against the charge under a justifying code.",
tone: "text-brand-indigo bg-brand-soft dark:text-brand-violet",
},
{
icon: ShieldCheck,
title: "Finalize the exception",
desc: "Finalizing links it to the charge and lifts the policy-limit gate.",
tone: "text-brand-indigo bg-brand-soft dark:text-brand-violet",
},
{
icon: Check,
title: "Approve the charge",
desc: "With the gate lifted, the approval goes through and the charge clears.",
tone: "text-positive bg-positive-soft",
},
];
/**
* Vertical step diagram of how an over-limit charge gets cleared. Static and
* presentational — numbered/iconed nodes joined by a connector line, in the
* demo's brand style. Renders well in the narrow chat panel.
*/
export function ApprovalFlowDiagram() {
return (
<ol className="space-y-0">
{STEPS.map((step, i) => {
const Icon = step.icon;
const isLast = i === STEPS.length - 1;
return (
<li key={step.title} className="relative flex gap-3 pb-5 last:pb-0">
{!isLast && (
<span
aria-hidden
className="absolute left-[17px] top-9 h-[calc(100%-1.25rem)] w-px bg-hairline"
/>
)}
<span
className={`relative flex h-9 w-9 flex-none items-center justify-center rounded-full ${step.tone}`}
>
<Icon className="h-4.5 w-4.5" />
</span>
<div className="pt-1">
<p className="text-sm font-semibold leading-tight text-ink">
{step.title}
</p>
<p className="mt-0.5 text-xs leading-snug text-ink-muted">
{step.desc}
</p>
</div>
</li>
);
})}
</ol>
);
}
@@ -0,0 +1,42 @@
"use client";
import { createContext, useContext, useState } from "react";
import type { Member } from "@/app/api/v1/data";
import useTeam from "@/app/team/actions";
interface AuthContextType {
users: Member[];
currentUser: Member;
setCurrentUser: (user: Member) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuthContext = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error(
"useAuthContext must be used within an AuthContextProvider",
);
}
return context;
};
export const AuthContextProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const { team } = useTeam();
const [selectedUser, setSelectedUser] = useState<Member | undefined>();
const currentUser = selectedUser ?? team[0];
if (!currentUser) return null;
return (
<AuthContext.Provider
value={{ users: team, currentUser, setCurrentUser: setSelectedUser }}
>
{children}
</AuthContext.Provider>
);
};
@@ -0,0 +1,76 @@
"use client";
import { createContext, useContext, useState } from "react";
import { useAgent } from "@copilotkit/react-core/v2";
import { useReportSurface } from "./use-report-surface";
type SurfaceKind = "report" | "ogui";
interface CanvasValue {
activeSurfaceKind: SurfaceKind | null;
activeSurfaceId: string | null;
clear: () => void;
}
const CanvasContext = createContext<CanvasValue>({
activeSurfaceKind: null,
activeSurfaceId: null,
clear: () => {},
});
/** Minimal shape of an activity message in the agent's message list. */
type MaybeActivityMessage = {
id?: string;
role?: string;
activityType?: string;
};
/** The latest canvas surface (report or OGUI) in the stream, whichever is most recent. */
function useLatestCanvasSurface(): {
kind: SurfaceKind | null;
surfaceId: string | null;
} {
const { agent } = useAgent();
const { surfaceId: reportId } = useReportSurface();
const messages = agent?.messages as MaybeActivityMessage[] | undefined;
if (messages) {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m?.role !== "activity") continue;
if (m.activityType === "a2ui-surface")
return { kind: "report", surfaceId: reportId };
if (m.activityType === "open-generative-ui")
return { kind: "ogui", surfaceId: m.id ?? null };
}
}
return { kind: null, surfaceId: null };
}
/**
* Tracks whether a surface (A2UI report or OGUI) should occupy the content
* region, and which KIND. Derives from the latest surface activity in the
* agent's message stream and layers a local dismiss for the "← Back" control.
* Unique per-surface ids mean dismissing one never suppresses a later one.
*/
export function CanvasProvider({ children }: { children: React.ReactNode }) {
const { kind, surfaceId } = useLatestCanvasSurface();
const [dismissedId, setDismissedId] = useState<string | null>(null);
const active = !!surfaceId && surfaceId !== dismissedId;
const activeSurfaceId = active ? surfaceId : null;
const activeSurfaceKind = active ? kind : null;
const clear = () => setDismissedId(surfaceId);
return (
<CanvasContext.Provider
value={{ activeSurfaceKind, activeSurfaceId, clear }}
>
{children}
</CanvasContext.Provider>
);
}
export function useCanvas(): CanvasValue {
return useContext(CanvasContext);
}
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useRef } from "react";
import {
A2UIProvider,
A2UIRenderer,
useA2UIActions,
} from "@copilotkit/a2ui-renderer";
import { OpenGenerativeUIActivityRenderer } from "@copilotkit/react-core/v2";
import useCreditCards from "@/app/actions";
import { catalog } from "@/a2ui/catalog";
import { ReportDataProvider } from "@/a2ui/report-data";
import type { A2UIOp } from "@/a2ui/build-report-ops";
import { useReportSurface } from "./use-report-surface";
import { useOguiSurface } from "./use-ogui-surface";
import { useCanvas } from "./canvas-context";
export function ReportCanvas() {
const { activeSurfaceKind } = useCanvas();
if (activeSurfaceKind === "ogui") return <OguiCanvas />;
return <ReportSurfaceCanvas />;
}
/** OGUI surfaces render their sandboxed iframe full-region on the canvas. */
function OguiCanvas() {
const { content } = useOguiSurface();
if (!content) return null;
return (
<div className="h-full overflow-y-auto">
<div className="p-6 md:p-8" data-testid="ogui-surface">
{/* message/agent are required by the renderer's prop type but only
`content` is read; pass null to satisfy the type. */}
<OpenGenerativeUIActivityRenderer
activityType="open-generative-ui"
content={content}
message={null}
agent={null}
/>
</div>
</div>
);
}
function ReportSurfaceCanvas() {
const { transactions, policies } = useCreditCards();
return (
<ReportDataProvider value={{ transactions, policies }}>
<A2UIProvider catalog={catalog}>
<CanvasInner />
</A2UIProvider>
</ReportDataProvider>
);
}
function CanvasInner() {
const { operations, surfaceId } = useReportSurface();
const hasContent = operations.length > 0 && !!surfaceId;
return (
<>
{surfaceId ? (
<SurfaceMessageProcessor
operations={operations}
surfaceId={surfaceId}
/>
) : null}
{hasContent ? (
<div className="h-full overflow-y-auto">
<div className="a2ui-surface p-6 md:p-8" data-testid="a2ui-surface">
<A2UIRenderer surfaceId={surfaceId} />
</div>
</div>
) : null}
</>
);
}
/**
* Feeds the surface's operations into the A2UI provider. The activity content
* carries the FULL operation list on each snapshot, so we strip a duplicate
* createSurface once the surface exists (the MessageProcessor throws on it) and
* skip re-processing identical op lists. Mirrors the framework's built-in
* SurfaceMessageProcessor.
*/
function SurfaceMessageProcessor({
operations,
surfaceId,
}: {
operations: A2UIOp[];
surfaceId: string;
}) {
const { processMessages, getSurface } = useA2UIActions();
const lastHashRef = useRef("");
useEffect(() => {
if (!operations.length) return;
const hash = JSON.stringify(operations);
if (hash === lastHashRef.current) return;
lastHashRef.current = hash;
const isExisting = !!getSurface(surfaceId);
const ops = isExisting
? operations.filter((op) => !("createSurface" in op))
: operations;
if (!ops.length) return;
try {
processMessages(ops as Array<Record<string, unknown>>);
} catch (err) {
console.warn("[report-canvas] processMessages threw:", err);
}
}, [operations, processMessages, getSurface, surfaceId]);
return null;
}
@@ -0,0 +1,53 @@
"use client";
import { useAgent } from "@copilotkit/react-core/v2";
import type { OpenGenerativeUIContent } from "@copilotkit/react-core/v2";
const OGUI_ACTIVITY_TYPE = "open-generative-ui";
/** Minimal shape of an OGUI activity message in the agent's message list. */
type MaybeActivityMessage = {
id?: string;
role?: string;
activityType?: string;
content?: Record<string, unknown>;
};
/**
* The latest Open Generative UI surface in the agent's message stream.
*
* The OpenGenerativeUIMiddleware turns a `generateSandboxedUi` call into an
* `open-generative-ui` activity message whose content is the streamed
* css/html/js. We read it straight from `agent.messages`, mirroring
* `useReportSurface` rather than relaying through a side channel.
*
* `surfaceId` is the activity message id (stable per call) so the canvas
* dismiss can key on it without suppressing a later surface. This depends on
* the activity message carrying an `id`; a later e2e task verifies the surface
* mounts, and if `id` proves absent there it will be adjusted then.
*/
export function useOguiSurface(): {
content: OpenGenerativeUIContent | null;
surfaceId: string | null;
} {
const { agent } = useAgent();
// No manual useMemo: downstream consumers guard on values, and the React
// Compiler memoizes this derivation from agent.messages — a manual memo here
// can't be preserved by the compiler (react-hooks/preserve-manual-memoization).
const messages = agent?.messages as MaybeActivityMessage[] | undefined;
if (messages) {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (
message?.role === "activity" &&
message?.activityType === OGUI_ACTIVITY_TYPE
) {
return {
content: (message.content as OpenGenerativeUIContent) ?? null,
surfaceId: message.id ?? null,
};
}
}
}
return { content: null, surfaceId: null };
}
@@ -0,0 +1,52 @@
"use client";
import { useAgent } from "@copilotkit/react-core/v2";
import {
A2UI_OPERATIONS_KEY,
extractSurfaceId,
type A2UIOp,
} from "@/a2ui/build-report-ops";
/** Minimal shape of an A2UI activity message in the agent's message list. */
type MaybeActivityMessage = {
role?: string;
activityType?: string;
content?: Record<string, unknown>;
};
/**
* The latest A2UI report surface in the agent's message stream.
*
* The A2UI middleware turns the render_report tool result into an
* `a2ui-surface` activity message carrying `a2ui_operations`. We read that
* directly from `agent.messages` (the pattern the framework's own renderer and
* the reference apps use) rather than relaying through a side channel.
*/
export function useReportSurface(): {
operations: A2UIOp[];
surfaceId: string | null;
} {
const { agent } = useAgent();
// No manual useMemo: downstream consumers guard on values (SurfaceMessageProcessor
// hashes the ops; CanvasProvider compares the string surfaceId), and the React
// Compiler memoizes this derivation from agent.messages — a manual memo here
// can't be preserved by the compiler (react-hooks/preserve-manual-memoization).
const messages = agent?.messages as MaybeActivityMessage[] | undefined;
if (messages) {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (
message?.role === "activity" &&
message?.activityType === "a2ui-surface"
) {
const operations =
(message.content?.[A2UI_OPERATIONS_KEY] as A2UIOp[]) ?? [];
return {
operations,
surfaceId: operations.length ? extractSurfaceId(operations) : null,
};
}
}
}
return { operations: [], surfaceId: null };
}
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { Check } from "lucide-react";
import type { Card as ICard, ExpensePolicy } from "@/app/api/v1/data";
import { CardBrand } from "@/app/api/v1/data";
import { VisaWordmark, MastercardMark } from "@/components/card-visual";
import { cn } from "@/lib/utils";
/** Small bordered brand chip (Visa wordmark / Mastercard mark) shown at the
* left of each pickable row. Mirrors the chip used by the "New Card Request"
* approval render so the two surfaces read as one design language. */
function BrandChip({ type }: { type: CardBrand }) {
return (
<div className="flex h-9 w-12 flex-shrink-0 items-center justify-center rounded-md border border-hairline bg-surface">
{type === CardBrand.Visa ? (
<VisaWordmark className="h-4 w-auto text-[#1a1f71]" />
) : (
<MastercardMark className="h-6 w-auto" />
)}
</div>
);
}
/**
* In-chat card picker. Renders the user's cards as a tappable list — each row
* shows the brand mark, card type and masked last-4, plus the assigned policy
* as a subtle subtitle. Clicking a row calls `onSelect(card)` once and then
* locks the list into a selected/confirmed state (the other rows dim), echoing
* the one-shot behaviour of <ApprovalButtons/>.
*/
export function CardPicker({
cards,
policies = [],
onSelect,
heading = "Select a card",
}: {
cards: ICard[];
/** Optional — used only to show each card's assigned policy as a subtitle. */
policies?: ExpensePolicy[];
onSelect: (card: ICard) => void;
/** Picker heading; the agent passes a contextual reason (e.g. the policy). */
heading?: string;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
if (!cards.length) {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
No cards available to choose from.
</div>
);
}
return (
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-sm font-semibold text-ink">{heading}</h3>
<ul className="space-y-2">
{cards.map((card) => {
const policy = policies.find((p) => p.id === card.expensePolicyId);
const isSelected = selectedId === card.id;
const isDimmed = selectedId !== null && !isSelected;
return (
<li key={card.id}>
<button
type="button"
disabled={selectedId !== null}
aria-pressed={isSelected}
onClick={() => {
setSelectedId(card.id);
onSelect(card);
}}
className={cn(
"flex w-full items-center gap-3 rounded-xl border bg-surface px-3 py-2.5 text-left transition-all",
isSelected
? "border-brand ring-2 ring-brand ring-offset-1 ring-offset-surface"
: "border-hairline hover:border-brand/40 hover:bg-brand-soft",
isDimmed && "opacity-50",
selectedId === null && "cursor-pointer",
)}
>
<BrandChip type={card.type} />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1.5 text-sm font-semibold text-ink">
<span>{card.type}</span>
<span className="font-mono tracking-wider text-ink-muted">
{card.last4}
</span>
</p>
<p className="truncate text-xs text-ink-muted">
{policy ? `${policy.type} policy` : "No policy assigned"}
</p>
</div>
{isSelected && (
<Check className="h-5 w-5 flex-shrink-0 text-brand" />
)}
</button>
</li>
);
})}
</ul>
{selectedId && (
<p className="text-xs italic text-ink-muted">Card selected.</p>
)}
</div>
);
}
@@ -0,0 +1,127 @@
import type { Card as ICard } from "@/app/api/v1/data";
import { CardBrand } from "@/app/api/v1/data";
import { cn } from "@/lib/utils";
/** Inline VISA wordmark (white), for use on the dark gradient card face. */
export function VisaWordmark({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 780 500"
xmlns="http://www.w3.org/2000/svg"
aria-label="Visa"
>
<path
fill="currentColor"
d="M293.2 348.7l33.4-195.8h53.4l-33.4 195.8zM540.7 157.2c-10.6-4-27.2-8.3-47.9-8.3-52.8 0-90 26.6-90.2 64.6-.3 28.1 26.5 43.8 46.8 53.2 20.8 9.6 27.8 15.7 27.7 24.3-.1 13.1-16.6 19.1-32 19.1-21.4 0-32.7-3-50.3-10.2l-6.9-3.1-7.5 43.8c12.5 5.5 35.6 10.2 59.6 10.5 56.2 0 92.6-26.3 93-66.8.2-22.3-14-39.2-44.8-53.2-18.6-9.1-30.1-15.1-30-24.3 0-8.1 9.7-16.8 30.6-16.8 17.4-.3 30.1 3.5 39.9 7.5l4.8 2.3 7.2-42.7zM676.3 152.9h-41.3c-12.8 0-22.4 3.5-28 16.3l-79.4 179.5h56.2s9.2-24.2 11.3-29.5c6.1 0 60.8.1 68.6.1 1.6 6.9 6.5 29.4 6.5 29.4h49.7l-43.6-195.8zm-65.8 126.3c4.4-11.3 21.4-54.8 21.4-54.8-.3.5 4.4-11.4 7.1-18.8l3.6 17s10.3 47 12.4 56.6h-44.5zM232.2 152.9L180 283.6l-5.6-27c-9.7-31.2-39.9-65-73.7-81.9l47.9 173.8h56.6l84.2-195.6h-57.2"
/>
<path
fill="currentColor"
opacity="0.85"
d="M131.9 152.9H46.3l-.7 3.8c67.1 16.2 111.5 55.4 129.9 102.5L157.2 169c-3.2-12.5-12.7-15.7-25.3-16.1"
/>
</svg>
);
}
/** Overlapping-circles Mastercard mark. */
export function MastercardMark({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 780 500"
xmlns="http://www.w3.org/2000/svg"
aria-label="Mastercard"
>
<circle cx="312" cy="250" r="200" fill="#eb001b" />
<circle cx="468" cy="250" r="200" fill="#f79e1b" />
<path
d="M390 100.2c-49.7 38.3-81.6 98.1-81.6 165.8s31.9 127.5 81.6 165.8c49.7-38.3 81.6-98.1 81.6-165.8S439.7 138.5 390 100.2z"
fill="#ff5f00"
/>
</svg>
);
}
/**
* The vivid violet→indigo gradient credit card face. Purely presentational.
* Shows a masked number, holder, valid-thru and brand mark. `subtle` renders a
* dimmed/peeking variant for a card stacked behind the active one.
*/
export function GradientCreditCard({
card,
holder,
subtle = false,
className,
}: {
card: Pick<ICard, "last4" | "expiry" | "type">;
holder: string;
subtle?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"relative flex aspect-[1.586/1] min-h-[185px] w-full overflow-hidden rounded-[22px] p-5 text-white @container",
subtle
? "bg-gradient-to-br from-indigo-400/80 to-violet-500/80"
: "brand-gradient shadow-[0_16px_38px_hsl(252_83%_55%/0.34)]",
className,
)}
>
{/* Soft sheen + decorative orbs for depth. */}
<div
aria-hidden
className="pointer-events-none absolute -right-10 -top-16 h-44 w-44 rounded-full bg-white/15 blur-xl"
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-20 -left-10 h-44 w-44 rounded-full bg-white/10 blur-xl"
/>
<div className="relative flex h-full min-w-0 flex-1 flex-col justify-between gap-3">
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2">
{/* EMV chip */}
<div className="h-7 w-10 rounded-md bg-gradient-to-br from-amber-200 to-amber-400/80 shadow-inner" />
</div>
{card.type === CardBrand.Visa ? (
<VisaWordmark className="h-7 w-auto text-white" />
) : (
<MastercardMark className="h-9 w-auto" />
)}
</div>
<div className="space-y-3">
{/* Masked PAN. `clamp()` + nowrap keeps all four groups on a single
line at every card width (the wide dashboard hero AND the narrow
/-page cards) without ever wrapping or clipping the last group. */}
<p
className="whitespace-nowrap font-mono tracking-[0.16em] text-white/95"
style={{ fontSize: "clamp(0.95rem, 4.8cqw, 1.125rem)" }}
>
&nbsp;&nbsp;&nbsp;{card.last4}
</p>
<div className="flex items-end justify-between text-xs">
<div>
<p className="uppercase tracking-wide text-white/60">
Card holder
</p>
<p className="mt-0.5 text-sm font-medium tracking-wide">
{holder}
</p>
</div>
<div className="text-right">
<p className="uppercase tracking-wide text-white/60">
Valid thru
</p>
<p className="mt-0.5 text-sm font-medium tracking-wide">
{card.expiry}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,107 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CreditCard, Loader2 } from "lucide-react";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { Card } from "@/app/api/v1/data";
export function ChangePinDialog({
onDialogOpenChange,
dialogOpen,
loading,
onSubmit,
cards,
}: {
onDialogOpenChange: (open: boolean) => void;
dialogOpen: boolean;
loading: boolean;
onSubmit: (args: { pin: string; cardId?: string }) => void;
cards: Card[];
}) {
const [pin, setPin] = useState("");
const [cardId, setCardId] = useState("");
return (
<Dialog open={dialogOpen} onOpenChange={onDialogOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Change PIN</DialogTitle>
<DialogDescription>
Enter your new 4-digit PIN below. Make sure it&#39;s something you
can remember but hard for others to guess.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="col-span-4 justify-between"
>
{cardId
? (() => {
const c = cards.find((card) => card.id === cardId);
return c ? `${c.type} - ${c.last4}` : "Choose card";
})()
: "Choose card"}
<CreditCard className="ml-2 h-4 w-4 text-ink-muted" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{cards.map((card) => (
<DropdownMenuItem
key={card.id}
onClick={() => setCardId(card.id)}
>
<div className="flex items-center">
<div className="brand-gradient mr-2 rounded-full p-1">
<CreditCard className="h-4 w-4 text-white" />
</div>
{card.type} - {card.last4}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="pin" className="text-right">
New PIN
</Label>
<Input
id="pin"
type="password"
maxLength={4}
value={pin}
onChange={(e) => setPin(e.target.value)}
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button type="submit" onClick={() => onSubmit({ pin, cardId })}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
"Confirm Change"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,90 @@
"use client";
import { createContext, useContext, useMemo, useState } from "react";
/**
* Shared controller for the docked chat panel's conversation inbox.
*
* The panel (a `CopilotSidebar`) and the inbox overlay live in two different
* parts of the tree: the inbox + new-conversation buttons live INSIDE the
* sidebar's header slot, while the inbox overlay is rendered as a sibling of
* the sidebar (so it can cover the panel without being clipped by the chat
* view). This context bridges them — and carries the thread actions from
* `useThreadSelection` so the header and the inbox rows can switch / start
* conversations and collapse the inbox in one place.
*/
export interface ChatInboxContextValue {
isInboxOpen: boolean;
openInbox: () => void;
closeInbox: () => void;
toggleInbox: () => void;
/** The chat's active thread id (from useThreadSelection). */
selectedThreadId: string;
/** Load an existing conversation, then return to the chat view. */
selectConversation: (id: string) => void;
/** Start a fresh conversation, then return to the chat view. */
startNewConversation: () => void;
}
const ChatInboxContext = createContext<ChatInboxContextValue | null>(null);
export interface ChatInboxProviderProps {
children: React.ReactNode;
selectedThreadId: string;
onSelectThread: (id: string) => void;
onCreateThread: () => void;
}
export function ChatInboxProvider({
children,
selectedThreadId,
onSelectThread,
onCreateThread,
}: ChatInboxProviderProps) {
const [isInboxOpen, setIsInboxOpen] = useState(false);
const value = useMemo<ChatInboxContextValue>(
() => ({
isInboxOpen,
openInbox: () => setIsInboxOpen(true),
closeInbox: () => setIsInboxOpen(false),
toggleInbox: () => setIsInboxOpen((open) => !open),
selectedThreadId,
selectConversation: (id: string) => {
onSelectThread(id);
setIsInboxOpen(false);
},
startNewConversation: () => {
onCreateThread();
setIsInboxOpen(false);
},
}),
[isInboxOpen, selectedThreadId, onSelectThread, onCreateThread],
);
return (
<ChatInboxContext.Provider value={value}>
{children}
</ChatInboxContext.Provider>
);
}
/**
* Access the panel inbox controller. Returns a safe no-op fallback when used
* outside the provider so a stray render never throws.
*/
export function useChatInbox(): ChatInboxContextValue {
const ctx = useContext(ChatInboxContext);
if (ctx) return ctx;
return {
isInboxOpen: false,
openInbox: () => {},
closeInbox: () => {},
toggleInbox: () => {},
selectedThreadId: "",
selectConversation: () => {},
startNewConversation: () => {},
};
}
export default ChatInboxContext;
@@ -0,0 +1,283 @@
"use client";
import type { CSSProperties } from "react";
import { Archive, ArrowLeft, Inbox, Plus, Trash2 } from "lucide-react";
import { useThreads } from "@copilotkit/react-core/v2";
import { cn } from "@/lib/utils";
import { useChatInbox } from "./chat-inbox-context";
const UNTITLED_LABEL = "New conversation";
/**
* Inbox-style conversation list that paints over the docked chat panel's chat
* area. It is rendered as a sibling of the `CopilotSidebar` (not inside the
* chat view) so it can cover the full panel without being clipped, and is
* positioned to exactly overlap the docked panel on the right edge.
*
* Visibility is driven by two pieces of shared state:
* - `isInboxOpen` from {@link useChatInbox} (toggled by the panel header), and
* - `panelOpen` passed by the parent (the sidebar's modal-open state),
* so the inbox only shows while the panel itself is open.
*/
export function ChatInbox({
panelOpen,
showArchived,
onShowArchivedChange,
width,
}: {
panelOpen: boolean;
showArchived: boolean;
onShowArchivedChange: (next: boolean) => void;
width: number;
}) {
const {
isInboxOpen,
closeInbox,
selectedThreadId,
selectConversation,
startNewConversation,
} = useChatInbox();
const {
threads,
isLoading,
archiveThread,
deleteThread,
hasMoreThreads,
isFetchingMoreThreads,
fetchMoreThreads,
} = useThreads({
agentId: "default",
includeArchived: showArchived,
limit: 20,
});
const visible = isInboxOpen && panelOpen;
const handleArchive = (id: string) => {
if (id === selectedThreadId) startNewConversation();
Promise.resolve(archiveThread(id)).catch((err: unknown) => {
console.error("Unable to archive conversation", err);
});
};
const handleDelete = (id: string, title: string) => {
if (!window.confirm(`Delete "${title}"? This cannot be undone.`)) return;
if (id === selectedThreadId) startNewConversation();
Promise.resolve(deleteThread(id)).catch((err: unknown) => {
console.error("Unable to delete conversation", err);
});
};
return (
<aside
data-testid="chat-inbox"
aria-label="Conversations"
aria-hidden={!visible}
style={{ "--inbox-width": `${width}px` } as CSSProperties}
className={cn(
"fixed top-0 right-0 z-[1300] flex h-[100dvh] max-h-screen w-full flex-col",
"w-full md:w-[var(--inbox-width)]",
"border-l border-hairline bg-surface text-ink shadow-lift",
"transition-[opacity,transform] duration-200 ease-out",
visible
? "translate-x-0 opacity-100"
: "pointer-events-none translate-x-2 opacity-0",
)}
>
{/* Inbox header — mirrors the panel header height so it sits flush. */}
<header className="flex h-[68px] flex-shrink-0 items-center justify-between gap-2 border-b border-hairline px-4">
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-brand-soft text-brand-indigo dark:text-brand-violet">
<Inbox className="h-[18px] w-[18px]" />
</span>
<div className="min-w-0">
<p className="truncate text-sm font-bold leading-tight tracking-tight text-ink">
Conversations
</p>
<p className="truncate text-xs leading-tight text-ink-muted">
{threads.length}{" "}
{threads.length === 1 ? "conversation" : "conversations"}
</p>
</div>
</div>
<button
type="button"
aria-label="Back to chat"
title="Back to chat"
data-testid="chat-inbox-back"
onClick={closeInbox}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-ink-muted transition-colors hover:bg-brand-soft hover:text-brand-indigo focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface dark:hover:text-brand-violet"
>
<ArrowLeft className="h-[18px] w-[18px]" />
</button>
</header>
{/* New conversation + archived toggle. */}
<div className="flex items-center gap-2 px-4 pb-2 pt-3">
<button
type="button"
data-testid="inbox-new-conversation"
onClick={startNewConversation}
className="brand-gradient inline-flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2.5 text-sm font-semibold text-surface shadow-[0_8px_20px_hsl(252_83%_60%/0.28)] transition-transform hover:-translate-y-0.5 hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
<Plus className="h-4 w-4" />
New conversation
</button>
</div>
<label className="flex cursor-pointer select-none items-center gap-2 px-4 pb-2 text-xs font-medium text-ink-muted">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => onShowArchivedChange(e.target.checked)}
className="h-3.5 w-3.5 accent-[hsl(var(--brand))]"
/>
Show archived
</label>
{/* List. */}
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-2.5 pb-4 pt-1">
{isLoading && threads.length === 0 ? (
<InboxEmpty
title="Loading conversations…"
message="Fetching your recent conversations."
/>
) : threads.length === 0 ? (
<InboxEmpty
title="No conversations yet"
message="Start a new conversation to chat with the copilot."
/>
) : (
<>
{threads.map((thread) => {
const title = thread.name ?? UNTITLED_LABEL;
const selected = thread.id === selectedThreadId;
return (
<div
key={thread.id}
data-testid="inbox-thread-row"
className="group relative"
>
<button
type="button"
aria-current={selected ? "true" : undefined}
onClick={() => selectConversation(thread.id)}
className={cn(
"flex w-full items-center gap-3 rounded-2xl py-2.5 pl-3 pr-[5.5rem] text-left transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1 focus-visible:ring-offset-surface",
selected
? "bg-brand-soft shadow-[inset_0_0_0_1px_hsl(var(--brand)/0.25)]"
: "hover:bg-brand-soft/60",
thread.archived && "opacity-60",
)}
>
<span
aria-hidden
className={cn(
"h-8 w-1 flex-none rounded-full",
selected
? "bg-gradient-to-b from-brand-violet to-brand-indigo"
: "bg-hairline",
)}
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className={cn(
"flex items-center gap-1.5 truncate text-sm font-semibold tracking-tight",
thread.name
? selected
? "text-brand-indigo dark:text-brand-violet"
: "text-ink"
: "font-medium text-ink-muted",
)}
>
<span className="truncate">{title}</span>
{thread.archived && (
<span className="flex-none rounded-full bg-surface-muted px-1.5 py-0.5 text-[0.6rem] font-semibold text-ink-muted">
Archived
</span>
)}
</span>
<span className="truncate text-xs text-ink-muted">
{formatRelativeTime(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{!thread.archived && (
<button
type="button"
aria-label={`Archive ${title}`}
title="Archive"
onClick={() => handleArchive(thread.id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-ink-muted transition-colors hover:bg-brand-soft hover:text-brand-indigo focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:hover:text-brand-violet"
>
<Archive className="h-4 w-4" />
</button>
)}
<button
type="button"
aria-label={`Delete ${title}`}
title="Delete"
onClick={() => handleDelete(thread.id, title)}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-negative transition-colors hover:bg-negative-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
type="button"
disabled={isFetchingMoreThreads}
onClick={() => fetchMoreThreads?.()}
className="mt-1 inline-flex w-full items-center justify-center rounded-xl border border-hairline bg-surface-muted px-3 py-2 text-sm font-semibold text-ink-muted transition-colors hover:border-brand/30 hover:bg-brand-soft hover:text-brand-indigo disabled:opacity-60 dark:hover:text-brand-violet"
>
{isFetchingMoreThreads ? "Loading…" : "Load more"}
</button>
)}
</>
)}
</div>
</aside>
);
}
function InboxEmpty({ title, message }: { title: string; message: string }) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="flex max-w-[15rem] flex-col items-start gap-2 rounded-2xl border border-hairline bg-surface-muted/90 p-4">
<p className="text-sm font-bold text-ink">{title}</p>
<p className="text-xs leading-relaxed text-ink-muted">{message}</p>
</div>
</div>
);
}
/**
* Compact relative timestamp ("just now", "3h ago", "2d ago", or a date).
*/
function formatRelativeTime(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "Updated recently";
const diffMs = Date.now() - then;
const sec = Math.round(diffMs / 1000);
if (sec < 45) return "just now";
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.round(hr / 24);
if (day < 7) return `${day}d ago`;
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
}).format(then);
}
export default ChatInbox;
@@ -0,0 +1,124 @@
"use client";
import { MessagesSquare, SquarePen, X } from "lucide-react";
import { useCopilotChatConfiguration } from "@copilotkit/react-core/v2";
import { cn } from "@/lib/utils";
import { IDENTITY } from "@/lib/identity";
import { useChatInbox } from "./chat-inbox-context";
/**
* Custom header for the docked chat panel, supplied to `CopilotSidebar` via its
* `header` slot. It renders INSIDE the sidebar's own
* `CopilotChatConfigurationProvider`, so `useCopilotChatConfiguration()` is the
* live handle for the panel — `setModalOpen(false)` collapses the panel and the
* change propagates up to the wrapper-level provider the inbox overlay reads.
*
* Layout: a small violet→indigo brand chip + the assistant title on the left,
* and three actions on the right — open the conversation inbox, start a new
* conversation, and close the panel.
*/
export function ChatPanelHeader() {
const configuration = useCopilotChatConfiguration();
const { isInboxOpen, toggleInbox, startNewConversation } = useChatInbox();
const title = configuration?.labels.modalHeaderTitle ?? IDENTITY.assistant;
const closePanel = () => configuration?.setModalOpen?.(false);
return (
<header
data-testid="chat-panel-header"
className="flex h-[68px] flex-shrink-0 items-center justify-between gap-2 border-b border-hairline bg-surface/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-surface/80"
>
<div className="flex min-w-0 items-center gap-3">
<span className="brand-gradient flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl text-surface shadow-[0_6px_16px_hsl(252_83%_60%/0.4)]">
<svg
viewBox="0 0 24 24"
fill="none"
className="h-5 w-5"
aria-hidden="true"
>
<path
d="M4 13.5L9 7l4 4.5L20 4"
stroke="white"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="20" cy="4" r="2" fill="white" />
</svg>
</span>
<div className="min-w-0">
<p className="truncate text-sm font-bold leading-tight tracking-tight text-ink">
{title}
</p>
<p className="truncate text-xs leading-tight text-ink-muted">
{IDENTITY.brand}
</p>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<HeaderIconButton
label={isInboxOpen ? "Back to chat" : "Conversations"}
active={isInboxOpen}
onClick={toggleInbox}
testId="chat-inbox-toggle"
>
<MessagesSquare className="h-[18px] w-[18px]" />
</HeaderIconButton>
<HeaderIconButton
label="New conversation"
onClick={startNewConversation}
testId="chat-header-new-conversation"
>
<SquarePen className="h-[18px] w-[18px]" />
</HeaderIconButton>
<HeaderIconButton
label="Close chat"
onClick={closePanel}
testId="chat-header-close"
>
<X className="h-[18px] w-[18px]" />
</HeaderIconButton>
</div>
</header>
);
}
function HeaderIconButton({
label,
onClick,
active = false,
testId,
children,
}: {
label: string;
onClick: () => void;
active?: boolean;
testId?: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
aria-pressed={active}
title={label}
data-testid={testId}
onClick={onClick}
className={cn(
"inline-flex h-9 w-9 items-center justify-center rounded-full transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface",
active
? "bg-brand-soft text-brand-indigo dark:text-brand-violet"
: "text-ink-muted hover:bg-brand-soft hover:text-brand-indigo dark:hover:text-brand-violet",
)}
>
{children}
</button>
);
}
export default ChatPanelHeader;
@@ -0,0 +1,96 @@
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import {
CopilotSidebar,
useCopilotChatConfiguration,
} from "@copilotkit/react-core/v2";
import type { CopilotSidebarProps } from "@copilotkit/react-core/v2";
import { IDENTITY } from "@/lib/identity";
import { ChatPanelHeader } from "./chat-panel-header";
import { ChatInbox } from "./chat-inbox";
/** Docked panel width on desktop (px). Mobile falls back to full width.
* Sized so the always-on suggestion pills flow two-per-row instead of
* stacking into a single tall column. */
const PANEL_WIDTH = 560;
/**
* The docked chat experience: a right-side `CopilotSidebar` that pushes page
* content aside (CopilotSidebarView manages the body margin) plus an
* inbox-style conversation list that paints over the chat area.
*
* Why `CopilotSidebar` directly (no license bypass): the OSS demo ships no
* license token, so `CopilotKitProvider` wires `createLicenseContextValue(null)`
* whose `checkFeature` returns `true` for every feature. `CopilotSidebar`'s
* `checkFeature("sidebar")` therefore passes — no `InlineFeatureWarning` banner
* and no console warning. Verified visually as well.
*
* `threadId` is threaded through to `CopilotSidebar` (which forwards it to the
* underlying `CopilotChat`) so frontend-tool round-trips keep their thread
* anchor. The wrapper-level `CopilotChatConfigurationProvider` already supplies
* `hasExplicitThreadId`, which flows down to the chat.
*/
export function ChatPanel({ threadId }: { threadId: string }) {
const [showArchived, setShowArchived] = useState(false);
// Read the panel's open state from the configuration chain. The wrapper's
// provider stays in sync with the sidebar's internal modal state (open/close
// propagates upward), so this reflects whether the panel is currently docked.
const configuration = useCopilotChatConfiguration();
const panelOpen = configuration?.isModalOpen ?? false;
// Start the docked panel CLOSED for a clean dashboard first impression.
//
// `CopilotSidebar` accepts `defaultOpen={false}`, but it cannot win here: the
// v1 `CopilotKit` bridge (this app uses the v1 export) mounts its own
// top-level `CopilotChatConfigurationProvider` with `isModalDefaultOpen`
// hard-commented-out, so it defaults the modal OPEN (true) and that value
// cascades DOWN through every nested chat provider via their parent→child
// sync. There is no bridge prop to change that default, so we correct it once
// on mount. `ChatPanel` reads the wrapper-level provider, which has no
// explicit default and therefore delegates its setter to the bridge — so this
// one call flips the root closed and the change cascades to the sidebar. A ref
// guard makes it a one-time action; the floating toggle still opens the panel
// freely afterward, and `useLayoutEffect` runs before paint so it never
// flashes open.
const setModalOpen = configuration?.setModalOpen;
const didCloseRef = useRef(false);
useLayoutEffect(() => {
if (didCloseRef.current) return;
didCloseRef.current = true;
setModalOpen?.(false);
}, [setModalOpen]);
return (
<>
<CopilotSidebar
agentId="default"
threadId={threadId}
position="right"
width={PANEL_WIDTH}
defaultOpen={false}
// The `header` slot is typed as `SlotValue<typeof CopilotModalHeader>`,
// which expects a component carrying CopilotModalHeader's namespace
// statics (Title/CloseButton). A plain replacement component does not
// structurally match that, so we cast — the same pattern CopilotKit's
// own slot tests use for custom headers. `renderSlot` renders any
// component reference at runtime.
header={ChatPanelHeader as CopilotSidebarProps["header"]}
labels={{
modalHeaderTitle: IDENTITY.assistant,
welcomeMessageText: IDENTITY.greeting,
}}
/>
<ChatInbox
panelOpen={panelOpen}
showArchived={showArchived}
onShowArchivedChange={setShowArchived}
width={PANEL_WIDTH}
/>
</>
);
}
export default ChatPanel;
@@ -0,0 +1,820 @@
"use client";
import {
useAgentContext,
useComponent,
useHumanInTheLoop,
} from "@copilotkit/react-core/v2";
import { usePathname, useRouter } from "next/navigation";
import { z } from "zod";
import useCreditCards from "@/app/actions";
import { useAuthContext } from "@/components/auth-context";
import { useRecording } from "@/components/recording-context";
import { ApprovalButtons } from "@/components/approval-buttons";
import { RecordingSteps } from "@/components/recording-feed";
import { PendingApprovalsChat } from "@/components/wow/pending-approvals-chat";
import {
SpendingTrendChart,
BudgetUsageChart,
SpendBreakdownChart,
IncomeExpenseChart,
} from "@/components/analytics-charts";
import { ApprovalFlowDiagram } from "@/components/approval-flow-diagram";
import { PERMISSIONS } from "@/app/api/v1/permissions";
import { withOverLimit } from "@/lib/over-limit";
import { Button } from "./ui/button";
export enum Page {
Cards = "cards",
Team = "team",
}
export enum CardsPageOperations {
ChangePin = "change-pin",
}
export enum TeamPageOperations {
InviteMember = "invite-member",
RemoveMember = "remove-member",
EditMember = "edit-member",
}
export const AVAILABLE_OPERATIONS_PER_PAGE = {
[Page.Cards]: Object.values(CardsPageOperations),
[Page.Team]: Object.values(TeamPageOperations),
};
// Self-learning "teach a workflow" loop — the canonical procedure echoed to the
// agent when the officer saves the demonstrated workflow. It names the exact
// (justifying) code the officer used; finalizing such an exception is what lifts
// the over-limit gate (proven in scripts/over-limit-gate-smoke.mjs). This text
// lands in the thread, which is how the agent recalls the procedure later in the
// SAME session. The agent only ever sees the code, never its human label.
const canonicalProcedure = (code: string): string =>
`Saved workflow for clearing an over-limit charge: (1) open a policy ` +
`exception against the transaction with code ${code}, (2) finalize the ` +
`exception, then (3) approve the transaction. Finalizing a ${code} exception ` +
`lifts the policy-limit gate. Reuse this same procedure for any other ` +
`over-limit charge — do not ask how to proceed.`;
// A component dedicated to adding readables/actions that are global to the app.
//
// The self-learning teach/recall tools (offerWorkflowRecording,
// awaitDashboardDemonstration, saveLearnedWorkflow, openPolicyException,
// finalizePolicyException, approveTransaction) live HERE, not on the Credit
// Cards page, because the officer demonstrates on the /dashboard route: if these
// tools were registered by a route component they would unmount on navigation,
// the in-progress card would lose its render, and the followUp continuation
// after "I'm done" would never fire. Registered globally they survive route
// changes and render on whichever page the user is on.
const CopilotContext = ({ children }: { children: React.ReactNode }) => {
const { currentUser } = useAuthContext();
const pathname = usePathname();
const router = useRouter();
const {
cards,
policies,
transactions,
changeTransactionStatus,
openPolicyException,
finalizePolicyException,
} = useCreditCards();
const { beginRecording, endRecording, getDemonstratedCode } = useRecording();
// A readable of app wide authentication and authorization context.
// The LLM will now know which user is it working against, when performing operations.
// Given the respective authorization role, the LLM will allow/deny actions/information throughout the entire app.
useAgentContext({
description: "The current user logged into the system",
value: JSON.stringify(currentUser),
});
useAgentContext({
description:
"The available pages and operations, as well as the current page",
value: {
pages: Object.values(Page),
operations: AVAILABLE_OPERATIONS_PER_PAGE,
currentPage: pathname.split("/").pop() as Page,
},
});
// The app's cards, policies and transactions — the single agent-facing data
// readable (registered globally so it is present on every route). When the
// user names a charge by merchant/amount, the agent resolves it to the id
// here. `overLimit: true` is the symptom only — it does NOT reveal the unlock
// procedure (the agent still has to learn that).
useAgentContext({
description:
"The available credit cards, expense policies and transactions. When the " +
"user refers to a charge by merchant name and/or amount, resolve it to the " +
"matching transaction id here and pass that id to the transaction tools; " +
"never invent an id. `overLimit: true` on a transaction means it is over " +
"its policy limit and cannot be approved normally — it has no standing " +
"approval yet.",
value: JSON.stringify({
cards,
policies,
transactions: withOverLimit(transactions, policies),
}),
});
// Actions the current user is NOT permitted to perform (role-derived). Global
// so the agent applies the same permission rules on every route.
useAgentContext({
description:
"Actions the current user is NOT permitted to perform. An empty list " +
"means the user is permitted to use every available action. Only " +
"refuse an action if it appears in this list — never refuse for any " +
"other reason. When refusing, say the user lacks permission; do not " +
"tell them they are on the wrong page.",
value: Object.keys(PERMISSIONS).filter(
(key) =>
!PERMISSIONS[key as keyof typeof PERMISSIONS].includes(
currentUser.role,
),
),
});
// This action is a generic "fits all" action
// It's meant to allow the LLM to navigate to a page where an operation is available or probably available, and possibly activate the operation there.
// It is tired to the readable above, and requires that operations are implemented in their respective pages.
// The LLM here will redirect the user to a different page, and set an `operation` query param to notify the page of the requested action
// For example, you can find `change-pin` in the cards page, which is activated when `operation=change-pin` query param is sent
useHumanInTheLoop({
name: "navigateToPageAndPerform",
description: `
Navigate to a different page to perform an operation.
IMPORTANT: Only use this action when the user needs to go to a DIFFERENT page than the one they are currently on.
Do NOT use this if the user is already on the correct page - instead, use the page-specific tools directly.
For example, if the user is on the cards page and asks to add a card, do NOT use this action - use the addNewCard tool instead.
Only use this when the user is on the wrong page entirely (e.g., on team page but asking about cards).
`,
parameters: z.object({
page: z
.enum(["/cards", "/team", "/"])
.describe("The page in which to perform the operation"),
operation: z
.string()
.describe(
"The operation to perform. Use operation code from available operations per page. If the operation is unavailable, do not pass it",
)
.optional(),
operationAvailable: z
.boolean()
.describe("Flag if the operation is available"),
}),
followUp: false,
render: ({ args, respond }) => {
const { page, operation, operationAvailable } = args;
return (
<div className="flex items-center justify-center gap-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<div className="text-sm">
Navigate to <span className="font-semibold">{page}</span>?
</div>
<Button
variant="outline"
size="icon"
onClick={() => {
const operationParams = `?operation=${operation}`;
// `/cards` mirrors the dashboard; the card tools/operations
// (add card, change PIN) are registered on the home route, so
// card requests must land on `/` or the operation dies on
// arrival.
const target = page === "/cards" ? "/" : page!.toLowerCase();
// Client-side navigation: a full reload (window.location) tears
// down the chat panel mid-run, so the conversation — and the
// in-flight operation — is lost the moment we navigate.
router.push(
`${target}${operationAvailable ? operationParams : ""}`,
);
respond?.(page!);
}}
aria-label="Confirm Navigation"
className="h-12 w-12 rounded-full bg-brand-soft text-brand-indigo hover:bg-brand-soft/70 dark:text-brand-violet"
>
Yes
</Button>
<Button
variant="outline"
size="icon"
onClick={() => respond?.("cancelled")}
aria-label="Cancel Navigation"
className="h-12 w-12 rounded-full bg-surface-muted text-ink-muted hover:bg-surface-muted/70"
>
No
</Button>
</div>
);
},
});
// Generative-UI: the pending-approval queue, rendered IN the chat. Mirrors the
// dashboard's "Pending approval" tab behaviors (identical over-limit gating,
// exception filing, and teach-mode recording payloads) so the officer can
// triage and clear over-limit charges without leaving the conversation.
// Rendered via PendingApprovalsChat — the dashboard's 4-column table is
// ~550px wide and its Actions column lands past the ~375px chat card's edge
// (buttons render but cannot be clicked), so the chat uses a stacked layout
// with labeled actions instead. Display-only `useComponent` (not
// useFrontendTool) so the card persists in the transcript after the call;
// re-registers when the data changes, or the closure captures empty arrays.
useComponent(
{
name: "showPendingApprovals",
description:
"Show the queue of transactions awaiting approval (including over-limit " +
"charges) as an interactive list in the chat. Call this whenever the " +
"user asks what is pending, what needs approval, or to review pending or " +
"over-limit charges — do NOT list the transactions in plain text. After " +
"the list renders, add one short sentence pointing at what needs " +
"attention (e.g. how many are over their policy limit).",
parameters: z.object({}),
render: () => {
const pending = transactions.filter((t) => t.status === "pending");
if (!pending.length) {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
No transactions are pending approval.
</div>
);
}
return (
// `pointer-events-auto`: this is a `useComponent` (display-only) render,
// which CopilotKit paints with `pointer-events: none` on the assistant
// message. But this table is interactive (Approve / Deny / File policy
// exception), so opt its subtree back into pointer events or the row
// actions (incl. the "More actions" menu) are unclickable in the chat.
<div className="pointer-events-auto space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">
Pending approvals
</h3>
<PendingApprovalsChat
transactions={pending}
policies={policies}
openPolicyException={openPolicyException}
finalizePolicyException={finalizePolicyException}
onApprove={async (id) =>
(await changeTransactionStatus({ id, status: "approved" })).ok
}
onDeny={async (id) =>
(await changeTransactionStatus({ id, status: "denied" })).ok
}
/>
</div>
);
},
},
[transactions, policies],
);
// ── Generative-UI charts & diagrams ─────────────────────────────────────────
// Visualizations the agent can summon directly in the chat (display-only
// `useComponent`, so they persist in the transcript like showTransactions).
// All hand-rolled SVG/CSS in the brand style — no charting dependency. Each
// re-registers when the data it reads changes.
//
// Every chart description carries the same "chart + answer" rule: the chart
// replaces restating the raw numbers, NOT the answer itself. Without it the
// model renders the right chart and never addresses the user's actual
// question ("which policy is closest to its limit?" → chart, silence).
const CHART_ANSWER_RULE =
" After the chart renders, ALSO answer the user's specific question in " +
"one or two sentences grounded in the data — the chart replaces listing " +
"the raw numbers, not your answer. If the user asked no specific " +
"question, one short takeaway sentence is enough.";
useComponent(
{
name: "showSpendingTrend",
description:
"Render a chart of spending over time in the chat. Call this for any " +
"question about spend trends, history, or how spending has changed." +
CHART_ANSWER_RULE,
parameters: z.object({}),
render: () => (
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">Spending trend</h3>
<SpendingTrendChart transactions={transactions} />
</div>
),
},
[transactions],
);
useComponent(
{
name: "showBudgetUsage",
description:
"Render a chart of budget usage per expense policy (spent vs limit) in " +
"the chat. Call this for questions about budgets, limits, utilization, " +
"or which teams are close to or over their limit." +
CHART_ANSWER_RULE,
parameters: z.object({}),
render: () => (
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">
Budget usage by policy
</h3>
<BudgetUsageChart policies={policies} />
</div>
),
},
[policies],
);
useComponent(
{
name: "showSpendBreakdown",
description:
"Render a donut chart breaking spend down by team/policy in the chat. " +
"Call this for 'where is the money going', spend distribution, or " +
"breakdown-by-team questions." +
CHART_ANSWER_RULE,
parameters: z.object({}),
render: () => (
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">Spend breakdown</h3>
<SpendBreakdownChart policies={policies} />
</div>
),
},
[policies],
);
useComponent(
{
name: "showIncomeVsExpenses",
description:
"Render a chart comparing total income vs expenses (and the net) in " +
"the chat. Call this for cash-flow, income-vs-spend, or net-position " +
"questions." +
CHART_ANSWER_RULE,
parameters: z.object({}),
render: () => (
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">Income vs expenses</h3>
<IncomeExpenseChart transactions={transactions} />
</div>
),
},
[transactions],
);
useComponent(
{
name: "showApprovalFlow",
description:
"Render a diagram of how an over-limit charge gets cleared (file " +
"exception → finalize → approve). Call this when the user asks how " +
"approvals or over-limit charges work, or to explain the process.",
parameters: z.object({}),
render: () => (
<div className="space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">
Clearing an over-limit charge
</h3>
<ApprovalFlowDiagram />
</div>
),
},
[],
);
// ── Recall tools (open → finalize → approve) ───────────────────────────────
// Neutral descriptions: they must not say what each step accomplishes, name a
// code, or describe a sequence — the agent learns the order from the saved
// procedure (canonicalProcedure), never from the prompt.
// Open a draft policy exception against a transaction (human-in-the-loop).
useHumanInTheLoop({
// followUp:true so that during recall the agent CONTINUES after the
// exception is opened — it must chain to finalizePolicyException (and then
// approval) on its own. With followUp:false the run ended here and the
// recall stalled after opening the exception.
followUp: true,
name: "openPolicyException",
description:
"Open a draft policy exception against a transaction. Requires human approval.",
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
parameters: z.object({
transactionId: z.string(),
code: z.string(),
}),
render: ({ args, respond, status }) => {
const { transactionId, code } = args;
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">
Open policy exception
</h3>
<div className="text-sm space-y-1">
<p>
<span className="text-ink-muted">Transaction:</span>{" "}
{transactionId}
</p>
<p>
<span className="text-ink-muted">Code:</span> {code}
</p>
</div>
<ApprovalButtons
onApprove={async () => {
if (!transactionId || !code) {
respond?.("Missing transaction or exception code");
return;
}
const { ok, data, error } = await openPolicyException({
transactionId,
code,
});
// Directive result so the agent reliably chains the NEXT step
// during recall: finalize THIS exception (by id) before any
// approval. gpt-5.4-mini otherwise tends to jump straight to
// approving, which the gate then rejects.
respond?.(
ok
? `Policy exception opened as a DRAFT. Its exceptionId is "${data?.id ?? ""}" — this is the id of the exception, NOT the transaction id. Next, call finalizePolicyException with exceptionId "${data?.id ?? ""}" exactly. The transaction stays blocked until this exception is finalized — do not try to approve before then.`
: `Could not open exception: ${error}`,
);
}}
onDeny={() => respond?.("Denied by user")}
/>
</div>
);
},
});
// Finalize a policy exception (human-in-the-loop).
useHumanInTheLoop({
// followUp:true so the agent CONTINUES after finalizing to perform the
// approval — completing the open -> finalize -> approve recall chain.
followUp: true,
name: "finalizePolicyException",
description: "Finalize a policy exception. Requires human approval.",
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
parameters: z.object({
exceptionId: z.string(),
}),
render: ({ args, respond, status }) => {
const { exceptionId } = args;
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">
Finalize policy exception
</h3>
<div className="text-sm space-y-1">
<p>
<span className="text-ink-muted">Exception:</span> {exceptionId}
</p>
</div>
<ApprovalButtons
onApprove={async () => {
if (!exceptionId) {
respond?.("Missing exception id");
return;
}
const { ok, error } = await finalizePolicyException({
exceptionId,
});
respond?.(
ok
? "Exception finalized — the policy-limit gate is now lifted. Approve the transaction to complete it."
: `Could not finalize exception: ${error}`,
);
}}
onDeny={() => respond?.("Denied by user")}
/>
</div>
);
},
});
// Approve a transaction (human-in-the-loop). The final step of the recall
// chain, once the over-limit gate has been lifted. Single-purpose and
// neutral: it says nothing about exceptions or codes. Its description gates it
// to the post-unlock state so the agent does NOT fire it as the first response
// to an over-limit approval request — at Beat 1 it has no saved procedure and
// must offer to record instead.
useHumanInTheLoop({
followUp: true,
name: "approveTransaction",
description:
"Approve a single transaction. Only call this for a charge that can actually be approved now — either it is within its policy limit, or its over-limit gate has already been lifted by the earlier steps of your saved procedure. Never call this as the first response to an over-limit approval request.",
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
parameters: z.object({
transactionId: z.string(),
}),
render: ({ args, respond, status }) => {
const { transactionId } = args;
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<h3 className="text-lg font-semibold text-ink">
Approve transaction
</h3>
<div className="text-sm space-y-1">
<p>
<span className="text-ink-muted">Transaction:</span>{" "}
{transactionId}
</p>
</div>
<ApprovalButtons
onApprove={async () => {
if (!transactionId) {
respond?.("Missing transaction id");
return;
}
const { ok, error } = await changeTransactionStatus({
id: transactionId,
status: "approved",
});
respond?.(
ok
? `Transaction ${transactionId} approved.`
: `Could not approve transaction ${transactionId}: ${error}`,
);
}}
onDeny={() => respond?.("Denied by user")}
/>
</div>
);
},
});
// ── Self-learning "teach a workflow" loop ──────────────────────────────────
// The agent drives this as a narrated sequence once it's asked to approve an
// over-limit charge it has no saved procedure for (the sequencing rules live
// in api/copilotkit/[[...slug]]/route.ts): offer to record -> wait while the
// officer demonstrates the fix ON THE DASHBOARD -> summarize and save. All are
// followUp:true so the agent advances to the next beat after each card
// resolves. Recording mode (the canvas vignette) opens on "Start recording"
// and closes on "I'm done"/Save/Discard/Cancel; RecordingProvider ref-counts,
// so the dashboard's own begin/end brackets nest harmlessly inside this window.
// BEAT 2 — offer to record, after an approval is declined with no procedure.
useHumanInTheLoop({
followUp: true,
name: "offerWorkflowRecording",
description:
"Offer to record how the user handles a charge you have no saved procedure for. Call this immediately after you decline an over-limit approval you have no saved workflow for — do NOT just ask how to proceed.",
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
parameters: z.object({
transactionId: z
.string()
.describe("The transaction whose approval was just declined."),
}),
render: ({ args, respond, status, result }) => {
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
// Once the user has chosen, collapse to a static line so the
// "Start recording" button doesn't linger (or get clicked twice) — the
// live "Recording your workflow" card (awaitDashboardDemonstration) takes
// over from here. Branch on the resolved `result` (fresh on complete) — NOT
// isRecording, whose value in this render closure can be stale and wrongly
// show "not recording" right after Start recording. onDeny resolves
// "declined"; onApprove resolves the (non-"declined") directive string.
if (status === "complete") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
{result === "declined"
? "Okay — not recording."
: "Recording started — go ahead and demonstrate the fix on the dashboard."}
</div>
);
}
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-ink">
Record a workflow?
</h3>
<p className="text-sm text-ink-muted">
No saved procedure for this charge yet. Want me to record how you
handle it so I can do it myself next time?
</p>
</div>
<ApprovalButtons
approveLabel="Start recording"
denyLabel="Not now"
onApprove={() => {
beginRecording();
// Directive result. With a bare "started", gpt-5.4-mini tends to
// just SAY awaitDashboardDemonstration's "go ahead and I'll watch"
// line (from its description) instead of CALLING it — leaving this
// card frozen with no live recording card. Tell it explicitly to
// call the tool and not reply in prose (mirrors the save beat).
respond?.(
`Recording started. Now IMMEDIATELY call awaitDashboardDemonstration with transactionId "${args?.transactionId ?? ""}" — that tool renders the live "Recording your workflow" card and is how you watch. Do NOT reply in plain text; calling that tool is the only correct next step.`,
);
}}
onDeny={() => respond?.("declined")}
/>
</div>
);
},
});
// BEAT 3 — the officer demonstrates on the real dashboard. This card just
// waits: the actual file-exception + approve happens on /dashboard (Transactions
// -> Pending approval), which records to the active thread. When the officer
// returns and clicks "I'm done" we end recording and report the exception code
// they used (captured via the recording context) so the agent can summarize
// and save it. No deps array: getDemonstratedCode reads a ref, so the click
// handler always sees the latest code even though this card rendered before
// the officer filed anything on the dashboard.
useHumanInTheLoop({
followUp: true,
name: "awaitDashboardDemonstration",
description: `Wait while the user demonstrates how they clear this charge themselves. Call this after the user agrees to record (offerWorkflowRecording returned "started"). Do NOT give the user step-by-step directions or tell them where to click — you do not know the procedure, which is the whole point of watching. Say only something brief like "Go ahead and do it now and I'll watch and learn." When they finish you receive the exception code they used.`,
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
parameters: z.object({
transactionId: z
.string()
.describe("The transaction the user will demonstrate on."),
}),
render: ({ respond, status, result }) => {
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
// Once resolved, collapse so the "I'm done" button can't linger.
if (status === "complete") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
{result === "cancelled"
? "Recording cancelled."
: "Recording finished — saving the workflow…"}
</div>
);
}
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5" aria-hidden>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-negative opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-negative" />
</span>
<h3 className="text-lg font-semibold text-ink">
Recording your workflow
</h3>
<span className="ml-auto text-[0.65rem] font-semibold uppercase tracking-wide text-negative">
Rec
</span>
</div>
<p className="text-sm text-ink-muted">
I don&apos;t know how to do this yet go ahead and do it yourself
now and I&apos;ll watch and learn. Click{" "}
<span className="font-medium text-ink">I&apos;m done</span> when
you&apos;re finished.
</p>
</div>
{/* Live feed of the actions being captured — same chat card, so it
reads consistently with the other cards (not a floating overlay). */}
<RecordingSteps />
<ApprovalButtons
approveLabel="I'm done"
denyLabel="Cancel"
onApprove={() => {
endRecording();
const code = getDemonstratedCode();
// Directive result so the agent reliably renders the Save card as
// its next step instead of just asking "should I save this?" in
// prose (gpt-5.4-mini otherwise tends to summarize in text and
// stall, leaving the user nothing to click). saveLearnedWorkflow
// IS the way it asks.
respond?.(
code
? `The user filed a policy exception with code ${code} and approved the charge on the dashboard. Now call saveLearnedWorkflow with this transaction id and code "${code}" exactly — that renders the card that asks them to save it. Do NOT ask whether to save in plain text; the card is how you ask.`
: "The user finished on the dashboard, but no exception code was captured. Ask them which exception code they used, then call saveLearnedWorkflow with it.",
);
}}
onDeny={() => {
endRecording();
respond?.("cancelled");
}}
/>
</div>
);
},
});
// BEAT 4 — summarize and save. Echoes the demonstrated procedure back into the
// thread (the same-session recall mechanism) and closes recording mode.
useHumanInTheLoop({
followUp: true,
name: "saveLearnedWorkflow",
description:
"Summarize the procedure the user just demonstrated and ask to save it. Call this after awaitDashboardDemonstration reports a filed exception, passing the exact code from that result.",
available: PERMISSIONS.APPROVE_TRANSACTION.includes(currentUser.role),
parameters: z.object({
transactionId: z.string(),
code: z
.string()
.describe(
"The exception code the user demonstrated, taken from awaitDashboardDemonstration's result.",
),
}),
render: ({ args, respond, status, result }) => {
if (status === "inProgress") {
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
Loading
</div>
);
}
// Once resolved, collapse so the "Save workflow" button can't linger or be
// re-clicked. `result` carries the value passed to respond() on complete.
if (status === "complete") {
const saved =
typeof result === "string" && result.includes("status: saved");
return (
<div className="rounded-2xl border border-hairline bg-surface p-4 text-sm text-ink-muted shadow-soft">
{saved
? "Workflow saved — Ill reuse it for future over-limit charges."
: "Discarded — not saved."}
</div>
);
}
const { code, transactionId } = args;
return (
<div className="space-y-4 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-ink">
Save this workflow?
</h3>
<p className="text-sm text-ink-muted">
To clear an over-limit charge:
</p>
<ol className="list-decimal space-y-1 pl-5 text-sm text-ink">
<li>
Open a policy exception under code{" "}
<span className="font-mono text-xs">{code}</span>
</li>
<li>Finalize the exception</li>
<li>Approve the transaction</li>
</ol>
</div>
<ApprovalButtons
approveLabel="Save workflow"
denyLabel="Discard"
onApprove={() => {
endRecording();
// Resolve a result the agent recognizes as "saved" and that drives it
// to persist the procedure durably via save_memory (Option A — see the
// TEACH & RECALL prompt). The demonstrated charge was cleared BY the
// demonstration, so it is already approved; say so explicitly or the
// agent tends to re-run the just-saved procedure on that same charge —
// opening a redundant exception on an approved transaction.
respond?.(
`(status: saved) ${canonicalProcedure(code)} Now persist this durably: call save_memory with scope "project", kind "operational", and content describing this over-limit procedure using code "${code}". You learned this by watching the user clear transaction ${transactionId}, which is already approved now — do NOT apply the procedure to ${transactionId} again or re-approve it. The original request is complete; wait for the user's next instruction before acting.`,
);
}}
onDeny={() => {
endRecording();
respond?.("discarded");
}}
/>
</div>
);
},
});
return children;
};
export default CopilotContext;
@@ -0,0 +1,82 @@
import { Lock, Settings2 } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { GradientCreditCard } from "@/components/card-visual";
import { Progress } from "@/components/ui/progress";
import type { Card as ICard, ExpensePolicy } from "../app/api/v1/data";
import { formatCurrency } from "@/lib/utils";
export function CreditCardDetails({
card,
policy,
holder = "Northwind Finance",
onChangePinModalOpen,
}: {
card: ICard;
policy?: ExpensePolicy;
/** Cardholder name shown on the card face. */
holder?: string;
onChangePinModalOpen: () => void;
}) {
const usagePct =
policy && policy.limit > 0
? Math.min(100, (policy.spent / policy.limit) * 100)
: 0;
return (
<div className="flex flex-col gap-4 rounded-[26px] border border-hairline bg-surface p-4 shadow-soft transition-shadow hover:shadow-lift">
<GradientCreditCard card={card} holder={holder.toUpperCase()} />
{policy ? (
<div className="space-y-3 px-1">
<div className="flex items-center justify-between text-sm">
<span className="text-ink-muted">Credit limit</span>
<span className="font-semibold text-ink">
{formatCurrency(policy.limit)}
</span>
</div>
<Progress value={usagePct} />
<div className="flex items-center justify-between text-sm">
<span className="text-ink-muted">Available</span>
<span className="font-semibold text-positive">
{formatCurrency(policy.limit - policy.spent)}
</span>
</div>
</div>
) : (
<p className="px-1 text-sm text-ink-muted">
No expense policy assigned
</p>
)}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full">
<Settings2 className="mr-2 h-4 w-4" />
Manage Card
</Button>
</PopoverTrigger>
<PopoverContent className="w-56">
<div className="grid gap-3">
<h4 className="text-sm font-semibold leading-none text-ink">
Card options
</h4>
<div className="h-px w-full bg-hairline" />
<Button
variant="ghost"
className="w-full justify-start"
onClick={onChangePinModalOpen}
>
<Lock className="mr-2 h-4 w-4" />
Change PIN
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}
@@ -0,0 +1,57 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { GlassEngineProvider, useGlassEngine } from "./glass-engine-context";
const STORAGE_KEY = "northwind.glassEngine";
afterEach(() => window.localStorage.clear());
function makeWrapper(available: boolean) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<GlassEngineProvider available={available}>
{children}
</GlassEngineProvider>
);
};
}
describe("useGlassEngine", () => {
it("defaults to disabled when nothing is stored", () => {
const { result } = renderHook(() => useGlassEngine(), {
wrapper: makeWrapper(true),
});
expect(result.current.enabled).toBe(false);
expect(result.current.active).toBe(false);
});
it("toggle() flips enabled and persists to localStorage", () => {
const { result } = renderHook(() => useGlassEngine(), {
wrapper: makeWrapper(true),
});
act(() => result.current.toggle());
expect(result.current.enabled).toBe(true);
expect(result.current.active).toBe(true); // available && enabled
expect(window.localStorage.getItem(STORAGE_KEY)).toBe("true");
});
it("reads the persisted value on mount", () => {
window.localStorage.setItem(STORAGE_KEY, "true");
const { result } = renderHook(() => useGlassEngine(), {
wrapper: makeWrapper(true),
});
expect(result.current.enabled).toBe(true);
});
it("is never active when unavailable, even if localStorage says enabled", () => {
// The public-host safety property: a forced localStorage value cannot
// activate the pane when the deployment has not opted in.
window.localStorage.setItem(STORAGE_KEY, "true");
const { result } = renderHook(() => useGlassEngine(), {
wrapper: makeWrapper(false),
});
expect(result.current.enabled).toBe(true);
expect(result.current.available).toBe(false);
expect(result.current.active).toBe(false);
});
});
@@ -0,0 +1,77 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
const STORAGE_KEY = "northwind.glassEngine";
interface GlassEngineContextValue {
/** Deployment-level gate (server-provided). When false, Glass Engine is absent. */
available: boolean;
/** Presenter's per-session on/off (localStorage). Meaningful only when available. */
enabled: boolean;
/** The render condition for the pane: available AND enabled. */
active: boolean;
setEnabled: (next: boolean) => void;
toggle: () => void;
}
const GlassEngineContext = createContext<GlassEngineContextValue | undefined>(
undefined,
);
export function GlassEngineProvider({
available,
children,
}: {
available: boolean;
children: React.ReactNode;
}) {
// Default off; standard mode is the default experience. Read the persisted
// value lazily so SSR renders `false` and the client hydrates to the stored
// value on mount.
const [enabled, setEnabledState] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
// Client-only hydration from localStorage (an external system): SSR renders
// the `false` default and the client syncs the persisted value post-mount. A
// lazy initializer would read window during SSR and cause a hydration mismatch.
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional localStorage hydration
setEnabledState(window.localStorage.getItem(STORAGE_KEY) === "true");
}, []);
const setEnabled = useCallback((next: boolean) => {
setEnabledState(next);
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, String(next));
}
}, []);
const toggle = useCallback(() => setEnabled(!enabled), [enabled, setEnabled]);
// `active` is the security-relevant render gate: it can never be true on a
// deployment that did not opt in, regardless of what localStorage holds.
const active = available && enabled;
return (
<GlassEngineContext.Provider
value={{ available, enabled, active, setEnabled, toggle }}
>
{children}
</GlassEngineContext.Provider>
);
}
export function useGlassEngine(): GlassEngineContextValue {
const ctx = useContext(GlassEngineContext);
if (!ctx) {
throw new Error("useGlassEngine must be used within a GlassEngineProvider");
}
return ctx;
}
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { useCopilotChatConfiguration } from "@copilotkit/react-core/v2";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useGlassEngine } from "@/components/glass-engine-context";
import { useInspector } from "@/lib/inspector/store";
import { useInspectorEvents } from "./use-inspector-events";
import { TimelineTab } from "./timeline-tab";
import { MemoryTab } from "./memory-tab";
import { LearningTab } from "./learning-tab";
/** Matches CopilotSidebar's docked width (chat-panel.tsx PANEL_WIDTH = 440). */
const CHAT_WIDTH = 440;
export function InspectorPane() {
// Tap the live AG-UI stream regardless of which tab is open. Runs whenever the
// pane is mounted (Task 12 mounts it only when the deployment opted in), so the
// Timeline accumulates history even while the pane is collapsed.
useInspectorEvents();
const { active, setEnabled } = useGlassEngine();
const { clear } = useInspector();
const [tab, setTab] = useState("timeline");
// Slide in next to the chat: anchored to the chat's right-dock width when the
// chat is open, flush to the edge when it's closed.
const chatOpen = useCopilotChatConfiguration()?.isModalOpen ?? false;
if (!active) return null;
return (
<aside
// Desktop-only (mirrors CopilotSidebar's body-margin desktop gate). Fixed
// so it's independent of the body margin CopilotSidebar manages.
className="glass-surface fixed inset-y-0 z-30 hidden w-96 flex-col border-l border-hairline shadow-lift transition-[right] duration-300 md:flex"
style={{ right: chatOpen ? CHAT_WIDTH : 0 }}
>
<header className="flex items-center gap-2 border-b border-hairline px-3 py-2">
<h2 className="text-xs font-semibold text-ink">
Glass Engine live protocol inspector
</h2>
<button
onClick={clear}
className="ml-auto text-[10px] text-ink-muted hover:text-ink"
>
clear
</button>
<button
onClick={() => setEnabled(false)}
className="text-[10px] text-ink-muted hover:text-ink"
>
collapse
</button>
</header>
<Tabs
value={tab}
onValueChange={setTab}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList variant="underline" className="shrink-0 px-3 pt-2">
<TabsTrigger value="timeline">Timeline</TabsTrigger>
<TabsTrigger value="memory">Memory</TabsTrigger>
<TabsTrigger value="learning">Learning</TabsTrigger>
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto">
<TabsContent value="timeline">
<TimelineTab />
</TabsContent>
<TabsContent value="memory">
<MemoryTab />
</TabsContent>
<TabsContent value="learning">
<LearningTab />
</TabsContent>
</div>
</Tabs>
</aside>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAuthContext } from "@/components/auth-context";
import { useInspector } from "@/lib/inspector/store";
import type { PanelMemory } from "@/lib/intelligence/memory";
// Backstop only; the real trigger is a memory-kind event in the store.
const BACKSTOP_POLL_MS = 15_000;
/** The over-limit unlock is saved as project-scope, operational (Intelligence main's memory-kind enum). */
function isOverLimitProcedure(m: PanelMemory): boolean {
return m.scope === "project" && m.kind === "operational";
}
function parseMemories(value: unknown): PanelMemory[] {
if (
typeof value === "object" &&
value !== null &&
Array.isArray((value as { memories?: unknown }).memories)
) {
return (value as { memories: PanelMemory[] }).memories;
}
return [];
}
export function LearningTab() {
const { currentUser } = useAuthContext();
const role = currentUser?.role;
const memberId = currentUser?.id;
const { cards } = useInspector();
const [procedures, setProcedures] = useState<PanelMemory[]>([]);
const [disabled, setDisabled] = useState(false);
const [loaded, setLoaded] = useState(false);
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const fetchProcedures = useCallback(async () => {
try {
const res = await fetch("/api/memories", {
headers: {
Accept: "application/json",
...(role ? { "x-northwind-role": role } : {}),
...(memberId ? { "x-northwind-user-id": memberId } : {}),
},
});
if (res.status === 503) {
if (isMountedRef.current) {
setDisabled(true);
setLoaded(true);
}
return;
}
if (!res.ok) throw new Error(String(res.status));
const json: unknown = await res.json();
if (isMountedRef.current) {
setProcedures(parseMemories(json).filter(isOverLimitProcedure));
setDisabled(false);
setLoaded(true);
}
} catch {
if (isMountedRef.current) setLoaded(true);
}
}, [role, memberId]);
const memoryEvents = cards.filter((c) => c.kind === "memory");
// Fetch on mount + slow backstop poll. fetchProcedures is async — it setStates
// only after `await fetch`, so there is no synchronous cascading render.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch; setState is post-await
fetchProcedures().catch(() => {});
const handle = window.setInterval(
() => fetchProcedures().catch(() => {}),
BACKSTOP_POLL_MS,
);
return () => window.clearInterval(handle);
}, [fetchProcedures]);
// Event-driven: re-fetch the moment a memory tool-call streams in.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch; setState is post-await
if (memoryEvents.length > 0) fetchProcedures().catch(() => {});
}, [memoryEvents.length, fetchProcedures]);
const learned = procedures.length > 0;
if (disabled) {
return (
<div className="p-4 text-xs text-ink-muted">
<p className="font-medium text-ink">Requires Intelligence mode</p>
<p className="mt-1">
Durable self-learning needs the Intelligence backend. Set the
<code className="mx-1 rounded bg-surface-muted px-1">
INTELLIGENCE_*
</code>{" "}
env vars to enable it.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4 p-3">
<section>
<div className="mb-2 flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${learned ? "bg-positive" : "bg-ink-muted"}`}
/>
<h3 className="text-xs font-semibold text-ink">
Over-limit procedure {learned ? "learned" : "not yet learned"}
</h3>
</div>
{!loaded ? (
<p className="text-[11px] text-ink-muted">Loading</p>
) : learned ? (
<ul className="flex flex-col gap-1.5">
{procedures.map((p) => (
<li
key={p.id}
className="rounded border border-positive/40 bg-positive-soft p-2 text-[11px]"
>
<p className="text-ink">{p.content}</p>
<p className="mt-1 text-[10px] text-ink-muted">
learned from {p.sourceThreadIds.length}{" "}
{p.sourceThreadIds.length === 1 ? "thread" : "threads"}
</p>
</li>
))}
</ul>
) : (
<p className="rounded border border-hairline p-2 text-[11px] text-ink-muted">
No saved over-limit procedure yet. Ask the copilot to approve an
over-limit charge it will stall, offer to record, and you teach it
by demonstrating the policy exception. Once saved, it appears here
and a fresh thread recalls it.
</p>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-semibold text-ink">
Recall activity (this session)
</h3>
{memoryEvents.length === 0 ? (
<p className="text-[11px] text-ink-muted">
No memory tool calls yet. Recall/save events appear here as the
agent uses long-term memory.
</p>
) : (
<ol className="flex flex-col gap-1">
{memoryEvents.map((c) => (
<li
key={c.id}
className="rounded border border-hairline px-2 py-1 text-[11px] text-ink"
>
<span className="mr-1 inline-block h-2 w-2 rounded-full bg-positive align-middle" />
{c.title}
</li>
))}
</ol>
)}
</section>
</div>
);
}
@@ -0,0 +1,313 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuthContext } from "@/components/auth-context";
import { useInspector } from "@/lib/inspector/store";
import type { PanelMemory } from "@/lib/intelligence/memory";
// Backstop only — the primary refresh trigger is a memory-kind event landing in
// the store (see the memoryEventCount effect). Catches rare out-of-band changes.
const BACKSTOP_POLL_MS = 15_000;
const KIND_COLORS: Record<string, string> = {
topical: "bg-brand-soft text-brand-indigo",
episodic: "bg-brand-soft text-brand-violet",
operational: "bg-positive-soft text-positive",
};
const SCOPE_COLORS: Record<string, string> = {
user: "bg-surface-muted text-ink-muted",
project: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
};
function parseMemories(value: unknown): PanelMemory[] {
if (
typeof value === "object" &&
value !== null &&
Array.isArray((value as { memories?: unknown }).memories)
) {
return (value as { memories: PanelMemory[] }).memories;
}
return [];
}
function MemoryCard({
memory,
relevance,
}: {
memory: PanelMemory;
relevance?: number;
}) {
const kindColor =
KIND_COLORS[memory.kind] ?? "bg-surface-muted text-ink-muted";
const scopeColor =
SCOPE_COLORS[memory.scope] ?? "bg-surface-muted text-ink-muted";
const threadCount = memory.sourceThreadIds.length;
return (
<li className="flex flex-col gap-1 rounded border border-hairline p-2 text-xs">
<div className="flex flex-wrap items-center gap-1">
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${kindColor}`}
>
{memory.kind}
</span>
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${scopeColor}`}
>
{memory.scope}
</span>
</div>
<p className="text-[11px] leading-relaxed text-ink">{memory.content}</p>
{relevance !== undefined && (
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-muted">
<div
className="h-full rounded-full bg-brand-indigo"
style={{ width: `${Math.max(6, Math.round(relevance * 100))}%` }}
/>
</div>
)}
<div className="text-[10px] text-ink-muted">
{threadCount} {threadCount === 1 ? "source thread" : "source threads"}
</div>
</li>
);
}
function DisabledState() {
return (
<div className="p-4 text-xs text-ink-muted">
<p className="font-medium text-ink">Requires Intelligence mode</p>
<p className="mt-1">
Durable memory lives in the Intelligence backend. Set the
<code className="mx-1 rounded bg-surface-muted px-1">
INTELLIGENCE_*
</code>
env vars (see the README) to enable the memory store and recall.
</p>
</div>
);
}
export function MemoryTab() {
const { currentUser } = useAuthContext();
const role = currentUser?.role;
const memberId = currentUser?.id;
// The store's memory-kind cards are our change signal: memory only changes
// when the agent saves/recalls, which is exactly when these events fire.
const { cards } = useInspector();
const memoryEventCount = useMemo(
() => cards.filter((c) => c.kind === "memory").length,
[cards],
);
const [memories, setMemories] = useState<PanelMemory[]>([]);
const [disabled, setDisabled] = useState(false);
const [listError, setListError] = useState<string | null>(null);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [recallQuery, setRecallQuery] = useState("");
const [recallResults, setRecallResults] = useState<PanelMemory[] | null>(
null,
);
const [recallError, setRecallError] = useState<string | null>(null);
const [isRecalling, setIsRecalling] = useState(false);
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const headers = useCallback(
(extra?: Record<string, string>) => ({
Accept: "application/json",
...(role ? { "x-northwind-role": role } : {}),
...(memberId ? { "x-northwind-user-id": memberId } : {}),
...extra,
}),
[role, memberId],
);
const fetchList = useCallback(async () => {
try {
const response = await fetch("/api/memories", { headers: headers() });
if (response.status === 503) {
if (isMountedRef.current) {
setDisabled(true);
setHasLoadedOnce(true);
}
return;
}
if (!response.ok) throw new Error(`recall failed (${response.status})`);
const json: unknown = await response.json();
if (isMountedRef.current) {
setMemories(parseMemories(json));
setDisabled(false);
setListError(null);
setHasLoadedOnce(true);
}
} catch (err) {
if (isMountedRef.current) {
setListError(err instanceof Error ? err.message : "unknown error");
setHasLoadedOnce(true);
}
}
}, [headers]);
// Fetch on mount + a slow backstop poll for rare out-of-band changes.
// fetchList is async — it setStates only after `await fetch`, so there is no
// synchronous cascading render (what the rule guards against).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch; setState is post-await
fetchList().catch(() => {});
const handle = window.setInterval(
() => fetchList().catch(() => {}),
BACKSTOP_POLL_MS,
);
return () => window.clearInterval(handle);
}, [fetchList]);
// Event-driven refresh: re-fetch the instant a memory tool-call streams in.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch; setState is post-await
if (memoryEventCount > 0) fetchList().catch(() => {});
}, [memoryEventCount, fetchList]);
const runRecall = useCallback(
async (query: string) => {
const trimmed = query.trim();
if (trimmed.length === 0) return;
setIsRecalling(true);
setRecallError(null);
try {
const response = await fetch("/api/memories/recall", {
method: "POST",
headers: headers({ "Content-Type": "application/json" }),
body: JSON.stringify({ query: trimmed }),
});
if (!response.ok) throw new Error(`recall failed (${response.status})`);
const json: unknown = await response.json();
if (isMountedRef.current) setRecallResults(parseMemories(json));
} catch (err) {
if (isMountedRef.current) {
setRecallError(err instanceof Error ? err.message : "unknown error");
setRecallResults(null);
}
} finally {
if (isMountedRef.current) setIsRecalling(false);
}
},
[headers],
);
const maxScore =
recallResults && recallResults.length > 0
? Math.max(...recallResults.map((m) => m.score ?? 0))
: 0;
if (disabled) return <DisabledState />;
return (
<div className="flex flex-col gap-4 p-3">
<div>
<h3 className="text-xs font-semibold text-ink">Memory store</h3>
<p className="text-[10px] text-ink-muted">
Long-term memory. Updates as the agent saves and recalls.
</p>
</div>
<form
className="flex gap-1.5"
onSubmit={(e) => {
e.preventDefault();
runRecall(recallQuery).catch(() => {});
}}
>
<input
type="text"
placeholder="Recall by meaning…"
value={recallQuery}
onChange={(e) => setRecallQuery(e.target.value)}
aria-label="Recall memories by meaning"
className="flex-1 rounded border border-hairline bg-transparent px-2 py-1 text-xs text-ink placeholder:text-ink-muted focus:outline-none focus:ring-1 focus:ring-brand"
/>
<button
type="submit"
disabled={isRecalling || recallQuery.trim().length === 0}
className="rounded border border-hairline px-2 py-1 text-xs font-medium text-ink hover:bg-brand-soft disabled:opacity-40"
>
{isRecalling ? "…" : "Recall"}
</button>
</form>
{recallResults !== null && (
<section aria-label="Recall results">
<div className="mb-1.5 flex items-center justify-between">
<h3 className="text-xs font-semibold text-ink">
Semantic recall ({recallResults.length})
</h3>
<button
type="button"
onClick={() => {
setRecallResults(null);
setRecallError(null);
}}
className="text-[10px] text-ink-muted hover:text-ink"
>
Clear
</button>
</div>
{recallError ? (
<p className="text-[11px] text-negative">
Recall failed: {recallError}
</p>
) : recallResults.length === 0 ? (
<p className="text-[11px] text-ink-muted">
No memories matched that query.
</p>
) : (
<ul className="flex flex-col gap-1.5">
{recallResults.map((m) => (
<MemoryCard
key={m.id}
memory={m}
relevance={
maxScore > 0 ? (m.score ?? 0) / maxScore : undefined
}
/>
))}
</ul>
)}
</section>
)}
<section aria-label="Recalled memories">
{/* "Recalled", NOT "All": this is top-k semantic recall, not a complete
enumeration. No absolute count — the UI must not claim completeness. */}
<h3 className="mb-1.5 text-xs font-semibold text-ink">
Recalled memories
</h3>
{listError ? (
<p className="text-[11px] text-negative">
Unable to load memories: {listError}
</p>
) : !hasLoadedOnce ? (
<p className="text-[11px] text-ink-muted">Loading</p>
) : memories.length === 0 ? (
<p className="text-[11px] text-ink-muted">
Nothing recalled yet. Teach the agent a durable procedure and watch
it appear here.
</p>
) : (
<ul className="flex flex-col gap-1.5">
{memories.map((m) => (
<MemoryCard key={m.id} memory={m} />
))}
</ul>
)}
</section>
</div>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useRef } from "react";
import { useInspector } from "@/lib/inspector/store";
import type { StoredCard } from "@/lib/inspector/store";
const KIND_DOT: Record<StoredCard["kind"], string> = {
lifecycle: "bg-ink-muted",
error: "bg-negative",
"tool-call": "bg-brand-indigo",
"tool-result": "bg-brand-violet/60",
state: "bg-brand-violet",
"hitl-gate": "bg-amber-500",
custom: "bg-ink-muted",
memory: "bg-positive",
};
export function TimelineTab() {
const { cards } = useInspector();
// Auto-scroll to the newest card as events stream in, so the timeline reads
// as a live feed during the demo.
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
}, [cards.length]);
if (cards.length === 0) {
return (
<p className="p-4 text-sm text-ink-muted">
Send a message every AG-UI protocol event the run emits appears here,
live.
</p>
);
}
return (
<ol className="flex flex-col gap-2 p-3">
{cards.map((c) => (
<li key={c.id} className="rounded-xl border border-hairline p-2">
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 shrink-0 rounded-full ${KIND_DOT[c.kind]}`}
/>
<span className="text-xs font-medium text-ink">{c.title}</span>
</div>
{c.summary && (
<p className="mt-1 text-[11px] text-ink-muted">{c.summary}</p>
)}
<details className="mt-1">
<summary className="cursor-pointer text-[10px] text-ink-muted">
raw event
</summary>
<pre className="mt-1 max-h-48 overflow-auto rounded bg-surface-muted p-2 text-[10px] leading-relaxed text-ink">
{JSON.stringify(c.raw, null, 2)}
</pre>
</details>
</li>
))}
<div ref={endRef} aria-hidden />
</ol>
);
}
@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
// `@ag-ui/*` isn't a direct dep of this demo; react-core/v2 re-exports the
// client types (`export * from "@ag-ui/client"`), so `BaseEvent` comes from there.
import { useAgent, type BaseEvent } from "@copilotkit/react-core/v2";
import { eventToCard } from "@/lib/inspector/event-cards";
import { useInspector } from "@/lib/inspector/store";
/**
* Bridges the raw AG-UI event stream into the inspector store. Mount once
* inside both the CopilotKit provider and the InspectorStoreProvider.
*
* Ported from splat-demo (Intelligence #361); the governance_decision handling
* was dropped — banking has no server-side component governance.
*/
export function useInspectorEvents(): void {
const { agent } = useAgent();
const { pushCard } = useInspector();
useEffect(() => {
const sub = agent.subscribe({
onEvent({ event }: { event: BaseEvent }) {
const card = eventToCard(event);
if (card) pushCard(card);
},
});
return () => sub.unsubscribe();
}, [agent, pushCard]);
}
@@ -0,0 +1,370 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import {
CreditCard,
HelpCircle,
LayoutDashboard,
RotateCcw,
Telescope,
Users,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { Member } from "@/app/api/v1/data";
import { MemberRole } from "@/app/api/v1/data";
import { useAuthContext } from "@/components/auth-context";
import { useGlassEngine } from "@/components/glass-engine-context";
import { useRecording } from "@/components/recording-context";
import { ThemeToggle } from "@/components/ui/theme-toggle";
import { useAgentContext } from "@copilotkit/react-core/v2";
import { usePathname } from "next/navigation";
import { IDENTITY } from "@/lib/identity";
import { useCanvas } from "@/components/canvas/canvas-context";
import { ReportCanvas } from "@/components/canvas/report-canvas";
interface LayoutProps {
children: React.ReactNode;
resetEnabled?: boolean;
}
/** Compact violet→indigo logo mark used at the top of the floating rail. */
function BrandMark() {
return (
<span className="brand-gradient flex h-11 w-11 items-center justify-center rounded-2xl text-surface shadow-[0_8px_20px_hsl(252_83%_60%/0.4)]">
<svg
viewBox="0 0 24 24"
fill="none"
className="h-6 w-6"
aria-hidden="true"
>
<path
d="M4 13.5L9 7l4 4.5L20 4"
stroke="white"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="20" cy="4" r="2" fill="white" />
</svg>
</span>
);
}
function UserNavigation({
availableUsers,
currentUser,
onChangeUser,
}: {
availableUsers: Member[];
currentUser: Member;
onChangeUser: (user: Member) => void;
}) {
const getInitials = (name: string) => {
return (name || "X Y")
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase();
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-11 w-11 rounded-2xl p-0 hover:bg-brand-soft"
aria-label="Account menu"
>
<Avatar className="h-9 w-9">
<AvatarFallback>{getInitials(currentUser.name)}</AvatarFallback>
</Avatar>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end" side="right">
<div className="grid gap-4">
<div className="space-y-1">
<h4 className="font-semibold leading-none text-ink">
{currentUser.name}
</h4>
<p className="text-xs text-ink-muted">{currentUser.email}</p>
</div>
<div className="grid gap-1">
<h4 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">
Switch user
</h4>
{availableUsers.map((user) => (
<Button
key={user.id}
variant="ghost"
className="w-full justify-start gap-2 px-2"
onClick={() => onChangeUser(user)}
>
<Avatar className="h-6 w-6">
<AvatarFallback className="text-[0.6rem]">
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
<span className="truncate text-sm">
{user.name} (
{user.role === MemberRole.Admin
? user.role
: user.role == MemberRole.Assistant
? user.team + " " + user.role
: user.team}
)
</span>
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
}
export function LayoutComponent({
children,
resetEnabled = false,
}: LayoutProps) {
const { users, currentUser, setCurrentUser } = useAuthContext();
const {
available: glassAvailable,
active: glassActive,
toggle: toggleGlass,
} = useGlassEngine();
const pathname = usePathname();
useAgentContext({
description: "The current page where the user is",
value: pathname.split("/")[1] === "" ? "cards" : pathname.split("/")[1],
});
const { activeSurfaceId, clear } = useCanvas();
const handleReset = async () => {
// Native confirm keeps the booth tool dependency-free and reliable; a stray
// click can't nuke the demo mid-show.
if (
!window.confirm(
"Reset demo state? This clears all learned memories and restores pending charges.",
)
) {
return;
}
try {
const res = await fetch("/api/v1/dev/reset", { method: "POST" });
if (res.ok) {
// Full reload -> pristine client slate (fresh transactions, cleared
// canvas, new thread on next message).
window.location.reload();
} else {
window.alert(`Reset failed (HTTP ${res.status}). See the server logs.`);
}
} catch (err) {
window.alert(`Reset failed: ${err instanceof Error ? err.message : err}`);
}
};
// Navigating via the rail dismisses any stale surface.
useEffect(() => {
clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
return (
<div
className={cn(
"flex h-screen overflow-hidden bg-canvas transition-[padding] duration-300",
glassActive && "md:pr-96",
)}
>
{/* Floating icon rail. */}
<div className="flex flex-shrink-0 flex-col py-4 pl-4">
<aside className="glass-surface flex h-full w-[72px] flex-col items-center rounded-[28px] border border-white/60 px-2 py-5 shadow-lift dark:border-hairline">
<Link
href="/"
className="flex items-center justify-center"
aria-label={IDENTITY.brand}
>
<BrandMark />
</Link>
<nav className="mt-8 flex flex-1 flex-col items-center gap-3">
<NavItem
href="/dashboard"
icon={LayoutDashboard}
label="Dashboard"
active={pathname.startsWith("/dashboard")}
/>
<NavItem
href="/"
icon={CreditCard}
label="Credit Cards"
active={pathname === "/" || pathname.startsWith("/cards")}
/>
{currentUser.role === MemberRole.Admin ? (
<NavItem
href="/team"
icon={Users}
label="Team Management"
active={pathname.startsWith("/team")}
/>
) : null}
</nav>
<div className="flex flex-col items-center gap-3">
{resetEnabled && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleReset}
aria-label="Reset demo state"
className="flex h-10 w-10 items-center justify-center rounded-2xl text-ink-muted transition-colors hover:bg-brand-soft hover:text-brand-indigo focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
<RotateCcw className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Reset demo state</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{glassAvailable && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleGlass}
aria-pressed={glassActive}
aria-label="Glass Engine"
className={cn(
"hidden h-10 w-10 items-center justify-center rounded-2xl transition-all md:flex",
glassActive
? "brand-gradient text-surface shadow-[0_8px_18px_hsl(252_83%_60%/0.4)]"
: "text-ink-muted hover:bg-brand-soft hover:text-brand-indigo",
)}
>
<Telescope className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Glass Engine (advanced)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<ThemeToggle />
<UserNavigation
availableUsers={users}
currentUser={currentUser}
onChangeUser={setCurrentUser}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Help"
className="flex h-10 w-10 items-center justify-center rounded-2xl text-ink-muted transition-colors hover:bg-brand-soft hover:text-brand-indigo focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
<HelpCircle className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Help &amp; support</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</aside>
</div>
<div className="flex flex-1 flex-col overflow-hidden">
<header className="flex h-20 items-center justify-between px-6 md:px-10">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-ink-muted">
{IDENTITY.brand}
</p>
<h1 className="text-2xl font-bold tracking-tight text-ink">
Hello, {currentUser.name.split(" ")[0]}
</h1>
</div>
</header>
<main className="flex-1 overflow-y-auto px-2 pb-6 md:px-6">
{activeSurfaceId ? (
<div className="flex h-full flex-1 flex-col">
<div className="flex items-center gap-2 p-4">
<button
type="button"
onClick={clear}
className="inline-flex items-center gap-1 rounded-xl border border-hairline bg-surface px-3 py-1.5 text-sm text-ink shadow-soft"
>
Back to {pathname.split("/")[1] || "dashboard"}
</button>
</div>
<div className="min-h-0 flex-1">
<ReportCanvas />
</div>
</div>
) : (
children
)}
</main>
</div>
</div>
);
}
interface NavItemProps {
href: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
active?: boolean;
}
function NavItem({ href, icon: Icon, label, active = false }: NavItemProps) {
// Narrate nav clicks into the recorder HUD — a no-op unless a workflow is
// being recorded, so it only fires while the officer is demonstrating.
const { logStep } = useRecording();
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={href}
onClick={() => logStep(`Opened ${label}`)}
aria-current={active ? "page" : undefined}
className={cn(
"relative flex h-11 w-11 items-center justify-center rounded-2xl transition-all duration-200",
active
? "brand-gradient text-surface shadow-[0_8px_18px_hsl(252_83%_60%/0.4)]"
: "text-ink-muted hover:bg-brand-soft hover:text-brand-indigo",
)}
>
<Icon className="h-5 w-5" />
<span className="sr-only">{label}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p>{label}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,190 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import { useState } from "react";
import type { PolicyException } from "@/app/api/v1/data";
import {
POLICY_EXCEPTION_CODES,
isJustifying,
labelForExceptionCode,
} from "@/app/api/v1/policy-exception-codes";
import { useRecording } from "@/components/recording-context";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// This card exists to CLEAR an over-limit charge, and only a justifying code
// actually lifts the policy-limit gate server-side (see store.hasApprovedException
// + isJustifying). Offering a non-justifying code here is a foot-gun: it files,
// flips the row to "Cleared", but the subsequent approve is rejected 422. So we
// only present the codes that can genuinely authorize the override.
const JUSTIFYING_CODES = POLICY_EXCEPTION_CODES.filter((c) =>
isJustifying(c.code),
);
const DEFAULT_CODE = JUSTIFYING_CODES[0].code;
type ExceptionResult = {
ok: boolean;
data?: PolicyException;
error?: string;
};
interface Props {
transactionId: string;
openPolicyException: (args: {
transactionId: string;
code: string;
}) => Promise<ExceptionResult>;
finalizePolicyException: (args: {
exceptionId: string;
}) => Promise<ExceptionResult>;
onFiled?: (code: string) => void;
onCancel?: () => void;
}
/**
* Inline "file a policy exception" card — the in-chat twin of
* `PolicyExceptionModal`. Rendered as a chat-card (no `Dialog` chrome) so the
* officer's whole demonstration happens right in the conversation: see the
* over-limit symptom, file the exception, watch it get recorded. That's what
* makes the recorded demonstration feel like "the agent watched me do it here."
*
* Behaviour is identical to the modal:
* - shows the human label per code (codes persisted; the agent only ever sees
* the code, never the label — the learning invariant),
* - opens the exception via REST then immediately finalizes it.
*
* The submission is bracketed by `beginRecording()` / `endRecording()` so the
* canvas recording vignette pulses while the demonstration is captured.
*/
export function PolicyExceptionInline(props: Props) {
const [code, setCode] = useState<string>(DEFAULT_CODE);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [doneId, setDoneId] = useState<string | null>(null);
const { beginRecording, endRecording, noteDemonstratedCode, logStep } =
useRecording();
const submitDisabled = busy || code === "";
const handleSubmit = async (): Promise<void> => {
setBusy(true);
setError(null);
beginRecording();
try {
const opened = await props.openPolicyException({
transactionId: props.transactionId,
code,
});
if (!opened.ok || !opened.data) {
setError(opened.error ?? "Failed to open policy exception");
return;
}
const exceptionId = opened.data.id;
const finalized = await props.finalizePolicyException({ exceptionId });
if (!finalized.ok) {
setError(finalized.error ?? "Failed to finalize policy exception");
return;
}
// Surface the demonstrated code to the teach-mode context so the chat's
// awaitDashboardDemonstration card can report it to the agent — the
// demonstration happens on the dashboard, outside the chat HITL flow.
noteDemonstratedCode(code);
// Narrate the filing into the recorder HUD.
logStep("Filed the policy exception");
setDoneId(exceptionId);
props.onFiled?.(code);
} finally {
setBusy(false);
endRecording();
}
};
if (doneId !== null) {
return (
<div className="flex w-full items-center gap-2.5 rounded-2xl bg-positive-soft px-3.5 py-3 text-sm text-ink ring-1 ring-inset ring-positive/30">
<CheckCircle2 className="size-5 flex-shrink-0 text-positive" />
<span>
Exception <span className="font-mono font-medium">{doneId}</span>{" "}
filed ({labelForExceptionCode(code)}). You can approve now.
</span>
</div>
);
}
return (
<div className="w-full space-y-3 rounded-2xl border border-hairline bg-surface p-4 text-ink shadow-soft">
<div className="space-y-1">
<p className="text-sm font-semibold text-ink">
File a policy exception
</p>
<p className="text-xs text-ink-muted">
This transaction is over its policy limit. File an exception to
proceed.
</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="exception-code-inline" className="text-xs">
Code
</Label>
<Select value={code} onValueChange={setCode}>
<SelectTrigger id="exception-code-inline">
<SelectValue />
</SelectTrigger>
<SelectContent>
{JUSTIFYING_CODES.map((c) => (
<SelectItem key={c.code} value={c.code}>
<span className="font-mono text-xs text-ink-muted">
{c.code}
</span>
<span className="ml-2">{c.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
onClick={() => void handleSubmit()}
disabled={submitDisabled}
className="flex-1"
>
{busy ? "Filing…" : "File exception"}
</Button>
{props.onCancel && (
<Button
type="button"
size="sm"
variant="outline"
onClick={props.onCancel}
disabled={busy}
>
Cancel
</Button>
)}
</div>
{error !== null ? (
<p className="rounded-xl bg-negative-soft px-3.5 py-2.5 text-sm text-negative ring-1 ring-inset ring-negative/30">
{error}
</p>
) : null}
</div>
);
}
export default PolicyExceptionInline;

Some files were not shown because too many files have changed in this diff Show More