Files
2026-07-13 12:58:18 +08:00

549 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. |