<!--
SMS Copilot Console Playbook

Part of the free Claude Code template library from BILT AI — https://biltai.io/claude-templates

These are the actual playbooks used to build and ship production SaaS apps with
Claude Code. They're written to be handed to an agent as-is: point Claude at one
and it has the full pattern, the schema, the gotchas, and the "don't do this"
list. Paths, project refs, and org names have been replaced with placeholders
(<your-app>, <supabase-project-ref>, <your-github-org>) — search for "<" and
fill in your own before running.

Free to use, modify, and ship. No attribution required.
-->

# SMS Copilot Console Playbook

**Portable spec for rebuilding the FULL cold-SMS auto-reply system from BKE / Kompozy in any new app — the admin console (Activity + Analytics), the secret-gated callback API, the champion/challenger bandit, the self-improving mistakes knowledge base, the GHL Conversations integration, the four Trigger.dev workers, and the GitHub-Action "brain" that runs Claude on the Max subscription. One kickoff rebuilds the whole loop: poll GHL → queue → Claude classifies + drafts + adjudicates → deterministic decision gate in code → send via GHL. Judgment lives in Claude; policy and every side effect live in code.**

Built from the BKE / Kompozy implementation (LIVE dry-run verified 2026-07-15). Reference repo: `reference app`. Plan docs: `Active/KOMPOZY-COLD-SMS/TIER3_AUTOREPLY_PLAN.md`, `REPLY_KNOWLEDGE_BASE.md`, `SMS_COPILOT_ANALYTICS_GAMEPLAN.md`.

> **What the admin console shows** (the surface being templatized): the entire SMS Copilot menu lives at `/admin/dashboard/sms-copilot` with two sub-tabs — **Activity** (live dry-run feed, the reward funnel, champion/challenger A/B management, the learned-lessons KB, promotion history) and **Analytics** (volume & health, the GHL-reconciled funnel, per-frame/per-niche opener breakdown, sent-per-day trend, per-variant A/B detail). The console is a window into the engine — this playbook rebuilds both.

---

## 0. Use this spec

**Prerequisites (must already exist in the target app):**
- The admin console shell from [s-tier/ADMIN-CONSOLE-PLAYBOOK.md](../s-tier/ADMIN-CONSOLE-PLAYBOOK.md) — `requireAdmin()` / `isAdminResponse()` guard, the admin `TopNav`, the `--brand-*` CSS tokens + `--font-body` / `--radius-pill` / `--brand-accent-dim` / `--brand-accent-line` in `globals.css`, `createAdminSupabase()` (service-role) + `createServerSupabase()`.
- A **GoHighLevel** sub-account with a Private Integration Token — see [b-tier/GHL-INTEGRATION-PLAYBOOK.md](../b-tier/GHL-INTEGRATION-PLAYBOOK.md). The cold list is imported into GHL and tagged (`<campaign>`, `<campaign>_sent`, `<campaign>_<frame>`, `<campaign>_niche_<key>`); the openers are sent from GHL. This system only handles the **replies**.
- Trigger.dev project (4 scheduled workers).
- A **GitHub repo** with Actions enabled + your Claude subscription (the classify/draft brain runs `anthropic/claude-code-action@v1` on `CLAUDE_CODE_OAUTH_TOKEN`, so it runs on your existing Claude subscription). GitHub Actions needs a billing budget set or the workflows won't run.
- A `profiles` table with `plan` (for the paid-conversion reconciler) and `is_admin()` (for RLS).

**Outputs (what an operator can do when this ships):**
- A nav item **"SMS Copilot"** → `/admin/dashboard/sms-copilot` with Activity + Analytics sub-tabs.
- **Activity page:** a **Poll-now** button (fires the poll worker, auto-refreshes ~3 min), an **Autosend ON/OFF (dry-run)** master switch with a confirm gate, a 9-stat **reward funnel** (Contacts → Revealed → Replied → Answered-Q → Bridge-clicked → Signed-up → Converted, + DND + Queue), **champion/challenger A/B management** per kind (reveal / offer-bridge) with Approve/Reject/Promote/Reactivate + an inline "Add challenger" form + a **✨ Propose with AI** button (dispatches a Claude Max Action that designs a one-variable challenger and auto-activates it), a **learned-lessons mistakes KB** (confirm/reject/retire/edit a rule + "flag mistake" from any feed row), **promotion history**, and the **dry-run comparison feed** (per-lead: classification, variant, confidence, action badge, the drafted reply with a Copy button, and — once live — the sent text).
- **Analytics page:** windowed cohort (Today/Yesterday/7d/30d/90d/All), volume & health (openers sent, reply rate, opt-out rate, bounces), the full **GHL-reconciled funnel** (opener→reply→reveal→positive→bridge), per-**frame** and per-**niche** opener tables, an openers-sent-per-day bar chart, and per-variant reveal/offer A/B detail tables.
- The full engine: 2-min GHL poller, the secret-gated callback that applies the decision gate + sends, the daily bandit promote/propose cron, the 4×/day GHL-thread reconciler, the daily volume-snapshot cron, and the two GitHub-Action workflows (reply + propose).

**File budget:** 2 admin pages (~720 lines) + 2 view components, 1 sub-nav + 1 segment layout, 4 admin API routes, 5 secret-gated public routes, ~10 lib files (`repo.ts` ~680 lines, `analytics.ts`, `ghl.ts`, `ghl-analytics.ts`, `ghl-reconcile.ts`, `snapshot-repo.ts`, `bandit.ts`, `decision-gate.ts`, `dispatch.ts`, `lessons.ts`, `niche-map.ts`, `types.ts`), 4 Trigger.dev workers, 2 `.github/workflows/*.yml`, 4 `scripts/sms-copilot/*.mjs` CI handoff scripts, 2 `docs/specs/*.md` Action contracts, and ~9 migrations (00177–00184).

---

## 1. Architecture — the loop (read this first)

This is NOT a chatbot. It's a **poll → queue → offline-brain → callback → gate → send** pipeline where the LLM only produces a *classification + draft + self-adjudication*, and **all policy, side effects, sends, tags, and DND live in deterministic code** on the callback side. That split is the whole safety model.

```
┌─ Trigger.dev: sms-copilot-poll (*/2 8-20 ET) ──────────────────────────────┐
│  GHL /conversations/search → new inbound reply, settle-gated (75s) → build  │
│  ThreadPayload → sms_copilot_queue (status=pending) → dispatchReplyWorkflow  │
└────────────────────────────────────┬───────────────────────────────────────┘
                                      │  GitHub workflow_dispatch (sms-copilot-reply.yml)
                                      ▼
┌─ GitHub Action (Claude Opus on your Claude sub — NO DB/GHL creds) ──────────┐
│  1. fetch-queue.mjs  → POST /api/sms-copilot/claim-queue  (secret)          │
│       ← items[] (thread, revealVariant copy, bridgeVariant, bridgeLink) +   │
│         active lessons[]                                                     │
│  2. claude-code-action: read docs/specs/sms-copilot.md → per item:          │
│       classify (16-type taxonomy) + draft (from revealVariant.copy_template)│
│       + ADJUDICATE own draft (verify{safe,risk,reason}) → /tmp/results.json │
│  3. submit-results.mjs → POST /api/sms-copilot/reply-callback (secret)      │
└────────────────────────────────────┬───────────────────────────────────────┘
                                      ▼
┌─ /api/sms-copilot/reply-callback  (THE POLICY BRAIN, in code) ─────────────┐
│  decide() deterministic gate → adjudicator override → autosend gate →       │
│  code-owned bridge-link injection (AI NEVER owns the link) → A/B accrual →   │
│  GHL side effects (send / DND / tag / mark-read) ONLY when autosend ON →     │
│  insertLog (feed + few-shot corpus) → advance cursor → mark queue done →     │
│  caught mistakes → upsertLesson (mistakes KB)                               │
└─────────────────────────────────────────────────────────────────────────────┘

Parallel crons:  sms-copilot-promote (daily bandit promote/propose + paid reconcile)
                 sms-copilot-reconcile (4×/day — true funnel from GHL threads)
                 sms-copilot-ghl-snapshot (daily — cumulative sent volume)
Conversion loop: opener has ?t=<trade> link → /start → track click/signup → paid
```

**Why the offline brain:** Claude runs on the Max subscription inside a GitHub Action (`claude-code-action@v1`, model `claude-opus-4-8`), so classification+drafting run on your existing Claude subscription rather than metered API tokens. The Action holds **no Supabase or GHL credentials** — its only access is the two secret-gated callback endpoints. Every decision that has a real-world side effect (send an SMS, DND a lead, apply a tag) is re-derived in `decide()` on the server from the classification, never trusted from the model's free text.

**Dry-run first (the default).** `sms_copilot_campaign.autosend_enabled=false` means the whole loop runs — poll, classify, draft, log — but **touches nothing in GHL** (no send, no DND, no tags). The operator compares drafts against what they'd send in the Activity feed, then flips **Autosend ON** when agreement is high. There is also a global env kill switch (`SMS_COPILOT_AUTOSEND=off`).

---

## 2. Design system — the console look (copy verbatim)

> The SMS Copilot console uses a **softer, rounded design language** than the mono/hollow-chip email console: `--font-body`, cards at `borderRadius:10–12`, solid-fill status badges, and a **pill sub-nav**. It is built with a single inline `C` color-token object (NOT Tailwind classes) so it drops into any app that has the `--brand-*` tokens. Re-theme by swapping the tokens in `globals.css`; keep the semantic colors (green=champion/positive, blue=challenger/engaged, amber=proposed/held, red=DND/error).

### 2.1 The `C` token object (paste at module scope in each view)

```ts
// Activity view (SmsCopilotView.tsx)
const C = {
  bg: 'var(--brand-bg-0)', card: 'var(--brand-bg-1)', border: 'var(--brand-border)',
  text: 'var(--brand-text-0)', dim: 'var(--brand-text-2)',
  green: 'var(--brand-success)', amber: 'var(--brand-accent)', red: 'var(--brand-danger)',
  blue: '#3b82f6', chip: 'var(--brand-bg-2)', onColor: '#0b0e13',
};
// Analytics view adds: inset:'var(--brand-bg-2)', dim:'var(--brand-text-1)', faint:'var(--brand-text-2)', accent:'var(--brand-accent)'
```

`onColor` (`#0b0e13`, fixed near-black) is text that sits ON a saturated badge fill (green/blue/amber/red) — those backgrounds stay dark enough in both light and dark themes, so this one value is intentionally not tokenized.

### 2.2 The signature surface treatments (do not improvise — these ARE the look)

1. **Stat tile** — `background:C.card; border:1px C.border; borderRadius:10; padding:'14px 16px'; minWidth:120`. Label (`12px C.dim`) / value (`24px 700`, colored per-metric) / optional sub (`11px C.dim`). Analytics variant uses `C.inset` bg, uppercase label, `flex:'1 1 120px'`.
2. **Status badge** (variants) — **solid fill**: champion→green, challenger→blue, proposed→amber, retired→dim; `color:C.onColor; padding:'2px 8px'; borderRadius:6; uppercase; fontWeight:700`.
3. **Action badge** (feed) — **hollow**: `border:1px <color>; color:<color>`; sent→green, drafted→amber, skipped→dim, suppressed/send_failed/error→red, `held*`→amber.
4. **Pill sub-nav + range pills** — `borderRadius:var(--radius-pill)`; active = `border:1px var(--brand-accent-line); background:var(--brand-accent-dim); color:var(--brand-accent)`; inactive = `border:1px var(--brand-border); background:transparent; color:var(--brand-text-1)`.
5. **Card** — `background:C.card; border:1px C.border; borderRadius:10` (Activity) / `12` (Analytics); page container `padding:24` (Activity) / `'16px 24px 40px'` (Analytics), `background:C.bg; minHeight:100vh; fontFamily:var(--font-body)`.
6. **Callout banners** — dry-run/pending/error use `background:var(--brand-accent-dim); border:1px C.amber; color:C.amber`; "working" uses a blue-bordered card with a spinner.

### 2.3 Helpers every view repeats

```ts
function pct(n, d) { return !d ? '0%' : `${((n/d)*100).toFixed(1)}%`; }   // Activity: guards 0-denominator
// Analytics: pct(n:number|null) → '—' when null; rateOf(a,b)=b>0?a/b:null; num()=toLocaleString or '—'
function etDateTime(iso) { /* Intl.DateTimeFormat America/New_York, 'MMM d, h:mm a' + ' ET' */ }
function etDate(iso)     { /* America/New_York 'MMM d' */ }
```
**All timestamps render in Eastern (`America/New_York`, auto EST/EDT)** so the feed matches the wall clock we work on; rows are stored UTC. A `<Spinner>` (`@keyframes sms-spin` injected inline) and a `<CopyButton>` (clipboard, ✓ 1.5s) round out the shared components.

### 2.4 Nav wiring

Admin `TopNav` items array (from ADMIN-CONSOLE) gets one entry:
```ts
{ href: '/admin/dashboard/sms-copilot', label: 'SMS Copilot' }
```
The Activity/Analytics sub-tabs are NOT nav items — they're a **segment layout** (`sms-copilot/layout.tsx` renders `<SmsCopilotSubNav/>` above `{children}`) so the pill tabs sit over both pages without either view knowing. Sub-nav active logic: Activity is the section index (`pathname === BASE`); Analytics owns its subtree (`pathname.startsWith(BASE + '/analytics')`).

---

## 3. Auth — two guards, one system

### 3.1 Admin routes (`/api/admin/sms-copilot/*`) — `requireAdmin`
Same pattern as every admin route (ADMIN-CONSOLE). Role check on the user's RLS client; service-role client only handed back after `role === 'admin'`.
```ts
const ctx = await requireAdmin();
if (isAdminResponse(ctx)) return ctx;   // short-circuit 401/403
```

### 3.2 Public callback routes (`/api/sms-copilot/*`) — secret-gated, session-less
The GitHub Action has no user session, so the claim/callback endpoints authenticate with a shared secret compared in constant time. **Add all five to `PUBLIC_API_PREFIXES` in `src/proxy.ts`** or the edge default-deny 401s them:
```
/api/sms-copilot/claim-queue, /api/sms-copilot/reply-callback,
/api/sms-copilot/propose-context, /api/sms-copilot/propose-callback, /api/sms-copilot/track
```
Every gated handler opens with:
```ts
export const runtime = 'nodejs';
const secret = process.env.SMS_COPILOT_CALLBACK_SECRET;
if (!secret) return NextResponse.json({ error: 'not configured' }, { status: 503 });
const given = String(body.secret ?? '');
const ok = given.length === secret.length &&
  crypto.timingSafeEqual(Buffer.from(given), Buffer.from(secret));
if (!ok) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
```
`/track` is the exception — `click` is unauthenticated (public link), `signup` resolves the user **server-side from the session cookie** (never trusts a userId in the body).

---

## 4. Page — Activity (`sms-copilot/page.tsx` + `SmsCopilotView.tsx`)

Reference: `SmsCopilotView.tsx`. The server page (`force-dynamic`, `revalidate=0`) `Promise.all`s eight repo reads for `CAMPAIGN='kompozy_q_sms'` — `getCampaign / listVariants / recentLog(120) / getQueueCounts / getContactStats / listContactLabels / getPromotionLog(30) / listLessons(100)` — then enriches each log row with its lead label (`_lead`) + reveal variant (`_variant`) and hands everything to the client view.

**Header:** `<h1>SMS Copilot</h1>` + campaign label; right side = **Poll now** (spinner while polling) + **Autosend: ON / OFF (dry-run)** (green when on; confirm dialog before turning ON: *"The copilot will start sending reveal replies to real leads…"*).

**Async actions (client):**
- `pollNow()` → `POST /api/admin/sms-copilot/poll-now`, then `router.refresh()` every 12s for ~3 min (16 rounds) so the feed fills as the Action runs (~2-3 min). Guarded against double-fire; timers cleared on unmount.
- `proposeAI(kind)` → `POST variant {action:'propose_ai', kind}`, then auto-refresh 18 rounds (~3.6 min).
- `post(path, body)` → generic POST that **surfaces `{error}` via `alert()`** (e.g. "a challenger is already running — retire it first") then `router.refresh()`.
- `working` banner shows when `pollRunning || queue.classifying>0 || queue.pending>0`.

**Reward funnel (9 stat tiles):** Contacts · Revealed (`%of total`) · Replied to reveal (`%of revealed`, blue) · Answered Q positive (`%of revealed`, green) · Bridge clicked (`%of positive`) · Signed up (`%of clicked`) · Converted paid (`%of signed_up`, green) · DND (red) · Queue (`pending/classifying · err`). Data from `getContactStats` + `getQueueCounts`.

**Champion/challenger A/B card** — for each kind `[reveal, bridge]`:
- Resolves `champ` (status champion, prefer no `niche_key`), `chal` (challenger, no niche_key), `proposed[]`, `retired[]`.
- Each variant row: name + `<StatusBadge>` · `sent N / <reward> M (rate)` (reward = `positive` for reveal, `paid`/`conversion` for bridge) · `copy_template` (+ `tests: <rationale>` in blue if AI-proposed) · action buttons.
- Buttons dispatch `variantAction({action, ...})`: champ = none; challenger = **Promote now** (green); proposed = **Approve**/**Reject**; retired (in a `<details>`) = **Reactivate**.
- Inline **+ Add challenger** form: kind select (Reveal / Offer bridge) · name · "what one variable it tests" · copy textarea (with `{niche_plural}` merge hint) · **Add as proposal** (green, human) OR **✨ Propose with AI** (dispatches the Max Action, ~2-3 min, auto-activates when the slot is free).
- Hint line: `· A/B running (challenger gets ~20%)` when a challenger exists, else `· champion only — add a challenger to start testing`.

**Learned-lessons (mistakes KB) card** — the self-improving loop. Header shows `(N active, fed to the brain every run)`. Copy explains: *every caught mistake becomes a rule the classify job reads next run; confirm real ones (weighted up), reject false catches (stop feeding), retire solved ones; `recurrences` = how often the pattern still bit AFTER the lesson existed*. Each lesson row: source badge (adjudicator=blue / human=green / guard=amber) · `trigger_type` chip · status · `↻ recurrences` (red ≥3) · `w<weight>` · date · the `rule` text · actions (`Confirm` / `Edit rule` [window.prompt] / `Retire (solved)` / `Reject (false catch)`; retired/rejected show `Re-activate`). Active + history split into two `<details>`. `lessonAction({action, id, ...})` → `POST /api/admin/sms-copilot/lesson`.

**Promotion history card** (when non-empty): date · kind · promoted_name (green) · beat demoted_name · `+X.Xpt` margin · trigger.

**Dry-run comparison feed** (`Recent activity (N)`): each row = lead name + `••• last4` phone + classification chip + `variant X` chip (blue) + `conf 0.NN` + `<ActionBadge>` + ET time + **⚑ Flag mistake** button (window.prompt → human lesson). Body: `→ outcome` (colored by action), then `draft:` text with a **Copy** button, and (once live) `sent:` text in green if it differs from the draft.

---

## 5. Page — Analytics (`sms-copilot/analytics/page.tsx` + `AnalyticsView.tsx`)

Reference: `analytics/page.tsx`. `force-dynamic`. Range from `?range=` (`today|yesterday|7d|30d|90d|all`, default `all`); Today/Yesterday align to **ET calendar days** via `etDayStartMs()` (samples the ET offset at noon UTC to dodge the DST edge).

**Two sources, deliberately split (§5.1 is the load-bearing design):**
- The **reply FUNNEL** (opener-reply / reveal-sent / reply-to-reveal / positive / bridge-reached) + per-frame + per-niche come from `sms_copilot_reconcile` — **reconciled from the actual GHL conversation threads** (the copilot's own tables under-count ~3× because the poll only catches a reply while it's the last message, and dry-run never advances the funnel flags).
- The **per-variant A/B** tables come from `sms_copilot_contact` (copilot-tracked) — variant attribution isn't recoverable from free-text threads. **All-time uses the append-only variant counters** (true lifetime record the bandit reads; a retired variant keeps its history); a **date window uses the current contact rows** filtered by `created_at`.
- **Live GHL** supplies only sent / imported / opt-outs (3 count calls via `getOpenerVolume`); a GHL failure sets `ghlError` and shows a banner but leaves the reconciled funnel intact.

Page `Promise.all`: `getCampaign / listVariants / getPromotionLog(30) / fetchContacts(all) / listGhlDaily(120) / getReplyHealth / getReconcile`.

**Cards (top→bottom):**
1. **Volume & health** — Openers sent (sub: sent-to-date or imported) · Replied · Opener reply rate (green) · Opt-out rate (red if >3%; `optOuts` = leads who sent STOP/hard-stop, NOT our defensive DNDs which show as `suppressed`) · Bounces (carrier no-SMS replies). Footnote: carrier delivered/failed not tracked (needs per-message paging — deferred); link clicks need tracked-link events.
2. **Funnel** — 6 horizontal bars (Openers sent → Replied → Reveal sent → Replied to reveal → Positive approx → Bridge reached <offer-price>), each `% of sent · % of prev`. Reconciled, not date-filtered.
3. **Initial sends — which opener frame wins** — per-frame table (`t1/t2/t3`): Sent · Replied · Reply rate (▲ best highlighted green) · Reveal sent · Replied to reveal · Bridge; plus a per-niche table below.
4. **Openers sent / day** — 14-bar chart from the per-day delta of the cumulative GHL snapshot (`sms_copilot_ghl_daily`); empty-state until two snapshots exist.
5. **Reveal & offer A/B — per-variant detail** — two tables (reveal: Sent / Reply rate / Positive→bridge; offer: Sent / Click rate / Paid rate) with StatusPills, + promotion history list. Sub-copy documents the lifetime-vs-window and assigned-vs-sent semantics precisely.

---

## 6. Admin API routes (4) — reference

All `requireAdmin`. Reference dir: `api/admin/sms-copilot/`.

| Route | Body | Does |
|---|---|---|
| `poll-now` | — | `tasks.trigger('sms-copilot-poll', {})` → `{ok, runId}`. |
| `autosend` | `{campaignKey?, enabled?}` | `setAutosend(db, key, enabled === true)`. Strict boolean. |
| `variant` | `{action, id?, kind?, name?, copy_template?, rationale?}` | switch: `propose`(human) / `approve`(→challenger, refuses if one exists) / `reject`+`retire`(→retired) / `reactivate`(clone→challenger) / `promote`(`promoteChallenger('manual')`) / `propose_ai`(`dispatchProposeWorkflow` → 502 on fail). |
| `lesson` | `{action, id?, rule?, weight?, ghlContactId?, …}` | `confirm` / `reject` / `retire` / `activate` / `edit`(`updateLesson`) / `flag`(`insertHumanLesson`). |

Defaults `campaignKey='kompozy_q_sms'`, `kind='reveal'` everywhere.

---

## 7. Public secret-gated routes (5) — reference

All `runtime='nodejs'`, secret-gated (except `/track` click). Reference dir: `api/sms-copilot/`.

| Route | Called by | Contract |
|---|---|---|
| `claim-queue` | Action step 1 | `{secret, campaignKey, runId}`. `getPendingQueue(30)` → **atomically `markQueue('classifying', {dispatch_run_id})`** (overlapping runs get nothing) → per row resolve reveal variant via `pickRevealVariant()` (bandit) for new leads / keep existing for in-flight; if revealed, resolve the bound bridge variant + `ensureBridgeAssignment` + build `bridgeLink = <APP_URL>/start?t=<trade>` (**niche-only, NO per-lead token** — long tracked URLs read as spam). Returns `{items:[{queueId, ghlContactId, revealed, vars, tags, revealVariant, bridgeVariant, bridgeLink, messages}], lessons}` (active lessons only fetched when there's work). |
| `propose-context` | Propose Action step 1 | `{secret, campaignKey, kind}` → `{champion, priorVariants:[{name,status,copy,positive_rate,conversion_rate}], hasActiveChallenger}`. `hasActiveChallenger:true` tells the Action to abort (one-at-a-time). |
| `propose-callback` | Propose Action step 3 | `{secret, campaignKey, kind, proposals:[{name,copy_template,rationale}]}` (`.slice(0,3)`). Inserts non-dup proposals (`proposed_by:'ai'`); **auto-approves the FIRST to live challenger when the slot is free**; extras stay proposed backups. `campaignKey/kind` taken from the TRUSTED context file, never model output. |
| `reply-callback` | Action step 3 | **THE POLICY BRAIN — see §8.** `{secret, results:ClassifyResult[]}`, `maxDuration=120`. |
| `track` | `/start` page + signup | `{token, event}`. `click`→`trackClick` (unauth, `^sc[a-z0-9]+$` validated). `signup`→resolve user server-side→`trackSignup(token, user.id)`. |

### 7.1 `reply-callback` — the deterministic policy brain

Per result, **idempotent** (skips rows already `done`/`error`):
1. `getQueueById` + `getCampaign` + `getContact`; normalize classification + trim draft.
2. `decision = decide({...})` — the gate (§9.2).
3. **Adjudicator override:** if `decision.action==='AUTO_SEND' && !dnd &&` the verify verdict is unsafe → downgrade to `DRAFT_ONLY` + `review:true`. `verdictIsUnsafe` treats `safe===false/"no"/"unsafe"/0` as unsafe; a **missing verdict = unverified, never auto-safe**. `corrected:true` = the Action red-teamed and rewrote its own draft → auto-send the fix + bank the lesson.
4. `autosendActive = !killSwitch && campaign.autosend_enabled === true`.
5. **Cursor advances on EVERY path** (3-retry): `{last_processed_msg_id, last_processed_at, language}` — so a re-poll never re-sends.
6. **Code-owned bridge link:** when `AUTO_SEND && isPostReveal && answeredOperational && stage!=='bridged'`, strip any `{placeholder}` and any AI URL, then append the code-owned `<APP_URL>/start?t=<trade>`. **The AI never owns the link.**
7. **Shippability:** `len>0 && (!containsLink || isBridgeSend) && !hasPlaceholder && withinHours`.
8. **A/B accrual:** reveal `sent_count` credited on decision (dry-run) or on shippable send (autosend); `reply_count` on any post-reveal reply; `positive_count` + bridge `sent_count` when `answeredOperational`.
9. **GHL side effects — autosend ONLY:** `DND`→`setContactDnd`+tags+`markConversationRead`; `AUTO_SEND`→persist cursor+revealed BEFORE `sendSms`; else `held_*`. **Dry-run touches nothing in GHL.**
10. `insertLog(full snapshot)` (the feed + few-shot corpus — surfaces insert errors), `markQueue('done')`. On throw → `markQueue('error')` (cursor already advanced).
11. **Mistakes KB:** `decision.reviewCode`→`upsertLesson(guardLesson())`; adjudicator-held or self-healed→`upsertLesson(adjudicatorLesson())`.

Response `{ok, processed, outcomes: Record<action,count>}`.

---

## 8. The engine layer (lib + workers)

### 8.1 Trigger.dev workers (service-role client; redeploy Trigger.dev after ANY edit)

| Worker | Cron | Role |
|---|---|---|
| `sms-copilot-poll` | `*/2 8-20 * * *` ET, `maxDuration 120` | Per active campaign: `reapStaleQueue(15min)` → `searchRecentConversations(cursor-15min)` → skip if not campaign-tagged / last msg not inbound / dnd / already-processed / inside `settle_seconds` (75s) → build `ThreadPayload` (last 24 msgs) → `upsertContactFromThread` + `enqueue` → `setPollCursor` → `dispatchReplyWorkflow` if any enqueued or backlog. |
| `sms-copilot-promote` | `0 14 * * *`, `maxDuration 120` | `KINDS=['reveal']` (bridge is track-bound, not a bandit). Per campaign: `shouldPromote`→`promoteChallenger('auto')`+propose next; else `shouldDiscard`→retire+propose; if no challenger, activate a waiting `proposed` backup or `dispatchProposeWorkflow` (always-be-testing). Then **paid reconcile**: `listSignedUpUnconverted` → read `profiles.plan` → `markConverted` when in `PAID_PLANS`. |
| `sms-copilot-reconcile` | `0 9,13,17,21 * * *` ET, `maxDuration 600` | `reconcileCampaign(auth,{max:1500,concurrency:3})` → `upsertReconcile`. Derives the true funnel from GHL thread STRUCTURE (copilot tables under-count ~3×). Throws if all campaigns fail. |
| `sms-copilot-ghl-snapshot` | `10 6 * * *` ET, `maxDuration 180` | `getOpenerVolume(FRAMES)` → `upsertGhlDaily`. **Skips the write on partial GHL data** (`vol.error`) to protect the forward-only cumulative trend. |

### 8.2 `bandit.ts` — champion/challenger optimizer
`MIN_SAMPLE=50`, `COLD_START_EXPLORE=0.2`, `FLOOR=0.05`. `pickVariant(variants, kind)`: champion unless an active challenger exists; before `MIN_SAMPLE` route to the challenger at its `explore_ratio` (0.2 = 80/20, 0.5 = even first battle); after, **Thompson sampling** (Beta via Marsaglia-Tsang gamma) on `positive_count` (reveal) / `conversion_count` (bridge) with a 5% floor. `shouldPromote(champ, chal, margin=0.05)` = past min-sample AND rate lift ≥ margin. `shouldDiscard` = at min-sample AND lift < margin (incumbent wins ties; the loop always advances).

### 8.3 `decision-gate.ts` — `decide()` (policy in code, exact order)
**Policy (2026-07-15): auto-send the reveal for EVERYTHING except a clear no.** Clear negatives → DNC (DND + `_no`/`_stop` tag). Order: (1) hard-stop→DND; (2) already-dnd→NO_REPLY; (3) carrier-bounce→DND; (4) service-mismatch-no→DND; (5) pre-reveal service decline→DND; (6) polite-no→courtesy-close or DND +DNC; (7) out-of-business/competitor-referral→warm close +DNC; (8) polite-disengage→DND; (9) **post-reveal:** `max_auto_depth==='bridge'` & answeredOperational & stage≠bridged & not disinterest → AUTO_SEND (the $49 bridge); else deepAuto follow-up or DRAFT_ONLY; (10) non-English→DRAFT_ONLY; **else** empty→NO_REPLY / link-in-draft→DRAFT_ONLY / draft-claims-a-job→DRAFT_ONLY+`reviewCode:'job-claim'` / **else AUTO_SEND the reveal.** `withinBusinessHours` fails closed on an unknown tz. `campaign.max_auto_depth` default `'reveal'` (reveals only); `'bridge'` opts into auto-sending the money moment.

### 8.4 `ghl.ts` — GHL Conversations client
`GHL_BASE='https://services.leadconnectorhq.com'`, `Version:'2021-07-28'`, `Authorization: Bearer <PIT>`. `kompozyGhlAuth()` reads `GHL_KOMPOZY_Private_API_KEY` + `GHL_KOMPOZY_LOCATION_ID`. Endpoints: `searchRecentConversations` (`GET /conversations/search`, paginates via `startAfterDate`), `getMessages`, `sendSms` (`POST /conversations/messages {type:'SMS',contactId,message}`), `getContactVars` (`GET /contacts/{id}` + custom-field map, cached), `addContactTags`, `setContactDnd` (`PUT /contacts/{id} {dnd:true}`), `markConversationRead`. See [b-tier/GHL-INTEGRATION-PLAYBOOK.md](../b-tier/GHL-INTEGRATION-PLAYBOOK.md).

### 8.5 `ghl-analytics.ts` / `ghl-reconcile.ts` / `snapshot-repo.ts` / `dispatch.ts` / `lessons.ts` / `niche-map.ts`
- **ghl-analytics** — `getOpenerVolume` via `POST /contacts/search` count calls on tags (`<campaign>` imported, `<campaign>_sent` sent-denominator, `<campaign>_<frame>`, `<campaign>_niche_<key>`) + `dnd=true`; any failure → partial `error`.
- **ghl-reconcile** — `reconcileContact` derives funnel flags from thread structure (`replied`→`revealSent`=first outbound after first inbound→`repliedToReveal`→`positiveApprox`→`bridgeReached` via `/\$?49\b|<domain>|\/start\?/`), retries 429/503.
- **snapshot-repo** — `upsertGhlDaily` / `listGhlDaily` / `getReconcile` / `upsertReconcile`.
- **dispatch** — `dispatchWorkflow(file, inputs)` = `POST /repos/<REPO>/actions/workflows/<file>/dispatches` (`Bearer GITHUB_DISPATCH_TOKEN`, `ref:'master'`). `dispatchReplyWorkflow` → `sms-copilot-reply.yml`; `dispatchProposeWorkflow` → `sms-copilot-propose.yml`. Set `REPO` to the target app's repo.
- **lessons** — `GUARD_LESSON` canned rules per `ReviewCode` (`dedup_key:'guard:<code>'`, weight 2); `adjudicatorLesson` (`weight risk==='high'?3:1`, `rule=verify.reason`).
- **niche-map** — `NICHE_MAP` (per-niche `nichePlural`/`qIssue`), `startTradeFor(key)`, `nicheKeyFromTags`, `frameFromTags`. **App-specific — rewrite for the new app's segments.**

### 8.6 The GitHub-Action brain (2 workflows + 4 CI scripts + 2 specs)
- **`.github/workflows/sms-copilot-reply.yml`** — `workflow_dispatch{campaign_key}`, `concurrency:sms-copilot-reply`. Steps: `fetch-queue.mjs` → `claude-code-action@v1` (model `claude-opus-4-8`, `--allowedTools Read,Write,Bash`, `--max-turns 30`, reads `docs/specs/sms-copilot.md`, writes `/tmp/sms-results.json`) → `submit-results.mjs`. Runs only when `count≠0`.
- **`.github/workflows/sms-copilot-propose.yml`** — `{campaign_key, kind}`, `max-turns 20`, reads `docs/specs/sms-copilot-propose.md`; aborts if `hasActiveChallenger`.
- **`scripts/sms-copilot/*.mjs`** — `fetch-queue`, `submit-results`, `fetch-propose-context`, `submit-proposal`: thin POST wrappers using `SMS_COPILOT_CALLBACK_SECRET` + `APP_URL`, `/tmp` file handoff. **No Supabase/GHL creds ever enter CI** — the secret-gated endpoints are the only data access.
- **`docs/specs/sms-copilot.md`** — the classify+draft+adjudicate contract the Action reads (16-type taxonomy, draft from `revealVariant.copy_template` with `{niche_plural}` substitution, self-adjudication `verify{safe,risk,reason}`, **never send / never call GHL / never put a link in a draft / never run git**). Export this from the source app; it's the brain's system prompt.

---

## 9. Schema — migrations 00177–00184

All tables `enable row level security` + admin-only policy (`is_admin((select auth.uid()))`); the poll worker + callbacks write via **service-role (bypass RLS)**. All additive / non-destructive. Reference: `supabase/migrations/`.

| Migration | Adds |
|---|---|
| **00177_sms_copilot** | `sms_copilot_campaign` (PK `campaign_key`; `autosend_enabled bool default false`, `max_auto_depth default 'reveal'`, business hours, `timezone`, `settle_seconds default 75`, `poll_cursor`), `sms_copilot_variant` (6 counters, `status` champion/challenger/retired, **partial unique champion index** on `(campaign_key,kind,coalesce(niche_key,''))`), `sms_copilot_contact` (cursor + stage machine + reward flags + `reveal_variant_id`), `sms_copilot_queue` (`thread jsonb`, `status` machine, **partial unique active index** — never double-queue), `sms_copilot_log`. RPC **`sms_copilot_inc_variant(id,col,by)`** (allowlisted atomic increment, service-role). Seeds `kompozy_q_sms` campaign + reveal variants. |
| **00178_ab_lifecycle** | Widens status → adds `proposed`; adds `generation`, `promoted_at`, `retired_at`, `parent_variant_id`, `proposed_by`, `proposal_rationale`; `sms_copilot_promotion_log`. |
| **00179_conversion** | Contact: `bridge_variant_id`, `track_token`, `converted_user_id`, `clicked_at`, `signed_up_at`; **partial unique index on `track_token`**. Seeds bridge champion. |
| **00180_promote_atomic** | RPC **`sms_copilot_promote(campaign,kind,trigger,note)`** — retire-then-promote in one txn (can't 23505 the champion index and leave zero champions) + logs. **Partial unique challenger index** (one-at-a-time backstop). |
| **00180_ghl_daily** *(duplicate number — both apply)* | `sms_copilot_ghl_daily` (unique `(campaign_key,snapshot_date)`; `imported/sent/opt_outs`, `sent_by_frame/niche jsonb`). |
| **00181_reconcile** | `sms_copilot_reconcile` (one row/campaign; `sent/replied/reveal_sent/replied_to_reveal/positive_approx/bridge_reached` + `by_frame/by_niche jsonb`). |
| **00182_lesson** | `sms_copilot_lesson` (source guard/adjudicator/human, status active/retired/rejected, `rule NOT NULL`, `weight`, `dedup_key`, `recurrences`, `log_id`; **partial unique dedup index**). RPC **`sms_copilot_bump_lesson(id,by)`**. |
| **00183_explore_ratio** | `sms_copilot_variant.explore_ratio numeric default 0.2`. |
| **00184_reveal_v4** | Retires live arms + seeds the current reveal/bridge lineup (bridges bound to reveal track via `parent_variant_id`). **App-specific seed — rewrite copy for the new app.** |

Full column lists + the `repo.ts` contract (every function takes `db` first, atomic-flip patterns on `clicked`/`signed_up`/`converted`, `randomBytes(12)` token minting) in the reference files (§12).

---

## 10. Env vars + secrets

| Name | Where | Purpose |
|---|---|---|
| `SMS_COPILOT_CALLBACK_SECRET` | Vercel + GitHub Secrets | Bearer for all callbacks (`timingSafeEqual`). Unset → routes 503. |
| `SMS_COPILOT_AUTOSEND` | Vercel | Global kill switch; `off/false/0/no` blocks all sends. Unset does NOT enable — the DB `autosend_enabled` flag is primary. |
| `GHL_KOMPOZY_Private_API_KEY` + `GHL_KOMPOZY_LOCATION_ID` | Vercel + Trigger.dev | GHL PIT + sub-account. Rename per app. |
| `GITHUB_DISPATCH_TOKEN` | Vercel + Trigger.dev | PAT to `workflow_dispatch` the Actions. |
| `APP_URL` | GitHub Secrets | Callback base + bridge-link host. |
| `CLAUDE_CODE_OAUTH_TOKEN` | GitHub Secrets | Claude subscription OAuth (the brain runs on your existing Claude subscription). |
| `NEXT_PUBLIC_SUPABASE_URL` + `SUPABASE_SERVICE_ROLE_KEY` | Trigger.dev | Worker service-role client. |

GitHub Actions needs a **billing budget** set on the repo or workflows silently don't run (this also blocks the SEO auto-author Action if they share the repo).

---

## 11. Invariants to preserve (do not regress)

1. **Judgment in Claude, policy in code.** The Action returns classification + draft + adjudication ONLY. `decide()` re-derives every side effect server-side; never send/DND/tag from the model's free text.
2. **The AI never owns the bridge link.** Code strips any AI URL/placeholder and appends the code-owned `/start?t=<trade>`. Long per-lead tracked URLs read as spam — niche-only link.
3. **Dry-run touches nothing in GHL.** `autosend_enabled=false` runs the whole loop but makes zero GHL mutations. Two gates: the DB flag AND the `SMS_COPILOT_AUTOSEND` env kill switch.
4. **Cursor advances on every path**, before any send, with a 3-retry — a re-poll must never re-send. On a callback throw the row goes `error` with the cursor already advanced.
5. **Idempotent counters.** Atomic flips (`.eq('clicked',false)` / `signed_up` / `converted`) + the `sms_copilot_inc_variant` RPC = exactly-once under concurrency. Callbacks skip rows already `done`/`error`.
6. **One challenger per kind.** DB partial unique indexes on champion AND challenger; `approveProposal` refuses if a challenger exists; `sms_copilot_promote` retires-then-promotes atomically.
7. **Reconcile is the funnel source of truth.** The copilot tables under-count ~3×; Analytics reads `sms_copilot_reconcile` (GHL threads) for the funnel, contact rows only for per-variant A/B.
8. **Secret-gate + no CI creds.** The 5 public routes are `timingSafeEqual`-gated and added to `PUBLIC_API_PREFIXES`; the Action holds no Supabase/GHL creds.
9. **Snapshot skips partial data.** `sms-copilot-ghl-snapshot` never writes when GHL returns partial counts — the cumulative trend is forward-only.
10. **Mistakes KB is human-final.** `upsertLesson` never resurrects a retired/rejected lesson (a human's call sticks); recurrences still count so a "solved" pattern re-biting is visible.

---

## 12. Build sequence (slices — pause after each for review)

1. **Schema** — apply 00177→00184 via Management API, track applied status (SUPABASE-MIGRATION-PLAYBOOK). Rewrite the 00184 seed copy + `niche-map.ts` for the new app's segments.
2. **GHL client + repo** — `ghl.ts`, `repo.ts`, `types.ts`. Smoke-test `searchRecentConversations` + `getContactVars` against the live sub-account.
3. **Engine core** — `bandit.ts`, `decision-gate.ts`, `dispatch.ts`, `lessons.ts`, `analytics.ts`, `ghl-analytics.ts`, `ghl-reconcile.ts`, `snapshot-repo.ts`.
4. **Public routes** — the 5 secret-gated endpoints + `proxy.ts` allow-list. Test `claim-queue` + `reply-callback` with a hand-rolled `curl` (secret in the body) in **dry-run** (autosend off).
5. **GitHub Action brain** — 2 workflows + 4 CI scripts + 2 spec docs. Set repo Secrets + a billing budget. Manually `workflow_dispatch` the reply workflow; confirm a queued reply flows end-to-end to a logged draft.
6. **Workers** — poll / promote / reconcile / snapshot. Redeploy Trigger.dev. Confirm the 2-min poller enqueues and dispatches.
7. **Admin console** — segment layout + sub-nav, Activity page + view, Analytics page + view. Review against the BKE screenshots.
8. **Dry-run soak → go live** — watch the Activity feed for a day or two, flag mistakes to seed the KB, confirm draft agreement is high, then flip **Autosend ON**.

---

## 13. Kickoff prompt

> Execute the SMS Copilot Console Playbook at `<workspace>/Templates/a-tier/SMS-COPILOT-CONSOLE-PLAYBOOK.md` for this app. **Prerequisites:** the admin console shell (`requireAdmin`, `TopNav`, `--brand-*`/`--font-body`/`--radius-pill` tokens, `createAdminSupabase`) from `Templates/s-tier/ADMIN-CONSOLE-PLAYBOOK.md`; a GHL sub-account with a Private Integration Token (`Templates/b-tier/GHL-INTEGRATION-PLAYBOOK.md`) holding the tagged cold list; a Trigger.dev project; and a GitHub repo with Actions + a Claude Max `CLAUDE_CODE_OAUTH_TOKEN`.
>
> Mirror the BKE implementation: `src/app/admin/dashboard/sms-copilot/*` (2 pages + views + segment sub-nav), `src/app/api/admin/sms-copilot/*` (4 routes), `src/app/api/sms-copilot/*` (5 secret-gated routes), `src/lib/sms-copilot/*` (repo, ghl, bandit, decision-gate, analytics, dispatch, lessons, niche-map, types, ghl-analytics, ghl-reconcile, snapshot-repo), `src/trigger/sms-copilot-{poll,promote,reconcile,ghl-snapshot}.ts`, `.github/workflows/sms-copilot-{reply,propose}.yml`, `scripts/sms-copilot/*.mjs`, `docs/specs/sms-copilot*.md`, and migrations 00177–00184. When in doubt about a contract, READ the BKE file — the decision gate (§8.3), the reply-callback policy (§7.1), the design tokens (§2), and the schema (§9) must be byte-faithful.
>
> Inputs:
> - Campaign key + label + GHL location id: [e.g. `myapp_q_sms` / "MyApp Cold SMS"]
> - The niche/segment list + per-niche `{niche_plural}` / `{q_issue}` merge vars (rewrite `niche-map.ts` + the 00184 seed): [list]
> - The opener frames in play (tags `<campaign>_<frame>`): [e.g. t1/t2/t3]
> - The offer + bridge landing path (`/start?t=<trade>` → signup): [URL]
> - `max_auto_depth`: `'reveal'` (auto-send reveals only — SAFE DEFAULT) or `'bridge'` (auto-send the money moment)?
> - The classify/draft/adjudicate contract copy for `docs/specs/sms-copilot.md`: [export from BKE, adapt voice]
>
> Follow the §12 slice order. **Ship in dry-run** (`autosend_enabled=false`) — build the whole loop, watch the Activity feed, seed the mistakes KB, and only flip Autosend ON after draft agreement is high. Redeploy Trigger.dev after the workers slice. Set a GitHub Actions billing budget. Re-theme via the `--brand-*` tokens — do not hardcode Kompozy colors.

---

## 14. Reference files in BKE

**Pages/views:** `sms-copilot/page.tsx` · `SmsCopilotView.tsx` · `analytics/page.tsx` · `analytics/AnalyticsView.tsx` · `_components/SmsCopilotSubNav.tsx` · `layout.tsx`

**API:** `api/admin/sms-copilot/` (4 routes) · `api/sms-copilot/` (5 routes)

**Engine:** `lib/sms-copilot/` (repo, ghl, ghl-analytics, ghl-reconcile, snapshot-repo, bandit, decision-gate, dispatch, lessons, niche-map, types, analytics)

**Workers:** `sms-copilot-poll` · `sms-copilot-promote` · `sms-copilot-reconcile` · `sms-copilot-ghl-snapshot`

**Migrations:** 00177–00184 under `supabase/migrations/`.

**Sibling playbooks:** [b-tier/GHL-INTEGRATION-PLAYBOOK.md](../b-tier/GHL-INTEGRATION-PLAYBOOK.md) (the GHL client half) · [a-tier/EMAIL-AUTOMATION-CONSOLE-PLAYBOOK.md](EMAIL-AUTOMATION-CONSOLE-PLAYBOOK.md) (the email-channel analogue) · [a-tier/TRIGGER-DEV-WORKER-PLAYBOOK.md](TRIGGER-DEV-WORKER-PLAYBOOK.md) (worker pattern).

---

**Built:** 2026-07-16. **Reference implementation:** BKE / Kompozy SMS Copilot (Tier 3 auto-reply, campaign `kompozy_q_sms`, LIVE dry-run verified 2026-07-15). **Related memory:** `kompozy_sms_copilot_tier3`.
