From c9ed213279154350b39cac04ab477eeddfd12ad4 Mon Sep 17 00:00:00 2001
From: Martinxmaina
Date: Thu, 25 Jun 2026 18:27:16 +0300
Subject: [PATCH 1/8] docs(ai-assistant): add design spec for WhatsApp AI
assistant
LLM-wiki RAG knowledge base, autonomous reply with confidence gating,
human-in-the-loop escalation, editable prompts. Anthropic-only.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2026-06-25-wacrm-ai-assistant-design.md | 378 ++++++++++++++++++
1 file changed, 378 insertions(+)
create mode 100644 docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md
diff --git a/docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md b/docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md
new file mode 100644
index 0000000000..8b0a9de4f1
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md
@@ -0,0 +1,378 @@
+# AI Assistant for WhatsApp — Design Spec
+
+- **Date:** 2026-06-25
+- **Status:** Approved design — ready for implementation plan
+- **Author:** brainstormed with the maintainer
+- **Feature branch (planned):** `feat/ai-assistant`
+- **Migration:** `027_ai_assistant.sql`
+
+---
+
+## 1. Goal
+
+Add an AI assistant that answers inbound WhatsApp messages, **grounded
+only in a per-account knowledge base** ("LLM wiki"). When the model is
+confident the knowledge base covers the question, it **replies
+autonomously**. When it is unsure, it **escalates to a human** and goes
+silent on that conversation. Admins manage the knowledge base and edit
+the assistant's prompt from Settings.
+
+This is a customer-facing, autonomous-send feature on the official
+WhatsApp Business API — so the bias everywhere is **fail safe to a
+human**: any doubt, any error, any cap hit → escalate, never guess and
+never go silent.
+
+## 2. Locked decisions (from brainstorming)
+
+| Decision | Choice | Rationale |
+|---|---|---|
+| Audience / channel | Customer-facing on WhatsApp | Highest value; builds on existing webhook + inbox |
+| Engagement model | AI is first responder; autonomous send when confident; clean hand-off + escalate when unsure; goes silent once a human takes over; agent can hand back | Matches "responds immediately; if unsure add a human" |
+| Knowledge approach | **LLM wiki (long-context + prompt caching)**, not vector/RAG | One provider, no pgvector/embeddings/chunking, ships faster; fits a typical company KB. RAG deferred with an upgrade path |
+| Confidence detection | **LLM self-reported grounding + deterministic guardrails** (both must pass to auto-send) | The retrieval half of the original "hybrid" is N/A since we send the whole KB; guardrails cover the deterministic half |
+| Provider | **Anthropic only** (Claude) | One key; LLM-wiki needs no embeddings provider |
+| Where logic lives | Dedicated `src/lib/ai/` module wired into the existing WhatsApp webhook | Lowest latency, self-contained, testable; designed so it could later be a Flows node |
+| Default model | `claude-sonnet-4-6` (configurable; Haiku 4.5 for cheaper/faster) | Good support-reply quality/cost balance |
+| Cost guard | Per-account **daily reply cap** (default 500/day, editable) | Prevents runaway spend; cap hit → escalate |
+
+## 3. Scope
+
+**In v1**
+- DB migration: AI config table, knowledge base table, conversation AI
+ columns, AI reply audit log.
+- `src/lib/ai/` module: prompt assembly, decision core, Anthropic
+ client wrapper, escalation logic — pure/mockable where possible.
+- Webhook integration: AI auto-reply dispatched from `processMessage`,
+ gated on `!flowConsumed` and `ai_handling`.
+- Settings → "AI Assistant" tab: enable toggle, prompt editor, handoff
+ message, escalation keywords, business name + logo, **knowledge base
+ manager** (CRUD + `.txt`/`.md`/PDF upload → text), KB size meter.
+- Inbox hand-off UX: "Needs human" badge, AI message tag (`sender_type
+ = 'bot'`), Take over / Hand back to AI controls.
+- API routes for AI config + knowledge base CRUD (account-scoped,
+ admin+).
+- Unit tests for the decision core and prompt assembly.
+
+**Deferred (not in v1; upgrade paths designed in)**
+- Vector/RAG retrieval (turn on when a KB exceeds the context budget).
+- Usage/cost analytics dashboard (data is logged in v1 via
+ `ai_reply_log`; the UI comes later).
+- Customer-facing logo surface / web chat widget (logo stored in v1,
+ used only as persona context).
+- Multi-language tuning, voice-note transcription, image understanding.
+
+## 4. Data model — `supabase/migrations/027_ai_assistant.sql`
+
+Follows existing conventions: `account_id` tenancy column, RLS enabled,
+`is_account_member(account_id, min_role)` for policies (mirror
+migrations 017–026). Service-role writes from the webhook bypass RLS as
+the other engines already do.
+
+### 4.1 `ai_assistant_config` — one row per account
+| Column | Type | Notes |
+|---|---|---|
+| `id` | uuid pk | |
+| `account_id` | uuid unique not null | FK → accounts, `ON DELETE CASCADE` |
+| `enabled` | boolean not null default false | Master switch; off by default (opt-in) |
+| `system_prompt` | text not null | Editable prompt; seeded with a strong default (see §7.2) |
+| `handoff_message` | text | Sent to the customer on escalation; nullable = send nothing |
+| `escalation_keywords` | text[] not null default `'{}'` | Seeded: refund, cancel, complaint, lawyer, legal, human, agent, manager |
+| `business_name` | text | Persona context |
+| `logo_url` | text | Stored via existing storage bucket; persona context only in v1 |
+| `model` | text not null default `'claude-sonnet-4-6'` | Configurable |
+| `daily_reply_cap` | integer not null default 500 | Cap hit → escalate |
+| `created_at` / `updated_at` | timestamptz default now() | |
+
+**RLS:** select/insert/update for `is_account_member(account_id,
+'admin')`. (Read could be widened to any member if the UI needs it;
+keep admin-only in v1.)
+
+### 4.2 `knowledge_base_entries` — many per account (the "wiki pages")
+| Column | Type | Notes |
+|---|---|---|
+| `id` | uuid pk | |
+| `account_id` | uuid not null | FK → accounts, cascade |
+| `title` | text not null | |
+| `content` | text not null | Markdown/plain text; what gets fed to the model |
+| `source_type` | text not null default `'manual'` | CHECK in (`manual`, `file`) |
+| `source_filename` | text | Original filename when `source_type='file'` |
+| `enabled` | boolean not null default true | Disabled entries are excluded from the prompt |
+| `token_estimate` | integer | Cached ~token count for the size meter (chars/4 heuristic) |
+| `created_by_user_id` | uuid | Audit |
+| `created_at` / `updated_at` | timestamptz default now() | |
+
+**RLS:** all ops for `is_account_member(account_id, 'admin')`.
+
+### 4.3 `conversations` — add AI hand-off columns (ALTER)
+| Column | Type | Notes |
+|---|---|---|
+| `ai_handling` | boolean not null default true | Is the AI still driving this conversation? Set false when escalated or when a human replies |
+| `ai_escalated_at` | timestamptz | Set on escalation |
+| `ai_escalation_reason` | text | `low_confidence` / `keyword` / `error` / `cap_reached` / `human_takeover` |
+
+(No change to the `status` CHECK. On escalation we also set
+`status='pending'`, which is already an allowed value and reads as
+"needs attention" in the inbox.)
+
+### 4.4 `ai_reply_log` — one row per AI decision (audit + future cost view)
+| Column | Type | Notes |
+|---|---|---|
+| `id` | uuid pk | |
+| `account_id` | uuid not null | cascade |
+| `conversation_id` | uuid | |
+| `message_id` | uuid | the inbound `messages.id` that triggered it |
+| `decision` | text not null | CHECK in (`replied`, `escalated`, `skipped`, `error`) |
+| `confident` | boolean | model's self-report (null when skipped pre-LLM) |
+| `reason` | text | escalation reason / error summary |
+| `model` | text | |
+| `input_tokens` / `output_tokens` / `cache_read_tokens` | integer | from `usage` |
+| `latency_ms` | integer | |
+| `created_at` | timestamptz default now() | |
+
+**RLS:** select for `is_account_member(account_id, 'admin')`; inserts
+are service-role only.
+
+## 5. Module structure — `src/lib/ai/`
+
+| File | Responsibility | I/O |
+|---|---|---|
+| `admin-client.ts` | Lazy service-role Supabase client | mirrors `src/lib/flows/admin-client.ts` |
+| `config.ts` | Load `ai_assistant_config` for an account | DB read |
+| `knowledge-base.ts` | Load enabled KB entries; assemble the KB text block; estimate tokens | DB read + pure |
+| `prompt.ts` | **Pure.** Build the system block (persona + KB, cache-marked) and the message array from conversation history + inbound | pure |
+| `guardrails.ts` | **Pure.** Deterministic escalation rules (keyword match, explicit human request) | pure |
+| `decide.ts` | **Pure.** Given the model's structured response + guardrail result + cap state → `{action: 'reply'|'escalate', text?, reason?}` | pure |
+| `anthropic.ts` | Thin wrapper around `@anthropic-ai/sdk`: forces the `submit_answer` tool, returns `{answer, confident, reason, usage}`. Injectable for tests | network |
+| `reply.ts` | Orchestrator: `maybeReplyToInbound(...)` — loads config/KB, runs guardrails, calls the model, applies `decide`, sends or escalates, writes `ai_reply_log`. Wraps everything in try/catch → escalate on error | composes the above |
+| `send.ts` | Send an AI reply via the existing Meta send path and insert the outbound `messages` row with `sender_type='bot'` | network + DB |
+| `escalate.ts` | Set `ai_handling=false`, `ai_escalated_at`, `ai_escalation_reason`, `status='pending'`; optionally send `handoff_message` | DB + network |
+
+The pure modules (`prompt`, `guardrails`, `decide`) hold all the
+logic worth testing; `anthropic`/`send`/DB are thin and mocked.
+
+## 6. Runtime flow
+
+Integration point: `src/app/api/whatsapp/webhook/route.ts` →
+`processMessage()`, **after** the flow dispatch resolves `flowConsumed`
+and after the inbound `messages` row is inserted. The webhook already
+runs `processWebhook(...)` fire-and-forget before acking Meta 200, so
+no change to the ack path is needed (no double-send risk). The AI call
+is fire-and-forget with its own try/catch, exactly like the automation
+dispatch.
+
+```
+inbound message stored
+ │
+ ├─ flowConsumed? ── yes ─▶ skip AI (customer is in a bot menu/flow)
+ │ no
+ ▼
+maybeReplyToInbound({ accountId, conversationId, contactId, messageId, inboundText })
+ │
+ load ai_assistant_config
+ ├─ !enabled ─▶ skip
+ load conversation
+ ├─ ai_handling === false ─▶ skip (human owns it)
+ guardrails(inboundText, keywords)
+ ├─ keyword / "talk to a human" ─▶ escalate (reason=keyword), no LLM call
+ daily cap check (count today's `replied` in ai_reply_log)
+ ├─ cap reached ─▶ escalate (reason=cap_reached)
+ ▼
+ build prompt (persona + cached KB block + recent history + inbound)
+ call Claude, force submit_answer tool → { answer, confident, reason, usage }
+ ▼
+ decide(modelResult)
+ ├─ confident && answer ─▶ send (sender_type='bot') + log replied
+ └─ else ─▶ escalate (reason=low_confidence) + log escalated
+ ▼
+ any throw anywhere ─▶ escalate (reason=error) + log error
+```
+
+**Human-takeover detection:** when an agent sends a manual reply
+(`POST /api/whatsapp/send`), set `ai_handling=false` on that
+conversation in the same handler. This guarantees the AI stays silent
+the instant a human engages, even mid-stream.
+
+## 7. Prompt & decision contract
+
+### 7.1 Structured output
+Call Claude with a single forced tool `submit_answer`
+(`tool_choice: { type: 'tool', name: 'submit_answer' }`) whose
+`input_schema` is:
+```json
+{
+ "answer": "string — the reply to send the customer, empty if not confident",
+ "confident": "boolean — true ONLY if the knowledge base fully answers the question",
+ "reason": "string — short rationale, for the audit log"
+}
+```
+This makes the decision reliably parseable (no JSON-in-text scraping).
+
+### 7.2 System block (cache-marked)
+`system` is an array; the **KB block carries
+`cache_control: { type: 'ephemeral' }`** so it bills at ~10% on cache
+hits (5-min TTL). Order: stable persona/instructions first, then the
+assembled KB (most cacheable last-stable content).
+
+Seeded default `system_prompt` (editable):
+> You are the customer-support assistant for {business_name}. Answer
+> ONLY using the information in the KNOWLEDGE BASE below. If the
+> knowledge base does not clearly and fully answer the customer's
+> question, you MUST set `confident` to false and leave `answer` empty —
+> do not guess, do not use outside knowledge, do not make promises about
+> pricing, refunds, delivery dates, or policies that aren't written
+> here. Be concise, friendly, and match the customer's language.
+
+The assembled KB block is the concatenation of enabled
+`knowledge_base_entries` (`## {title}\n{content}`), wrapped in clear
+delimiters.
+
+### 7.3 Conversation history
+Include the last N inbound/outbound messages (default N=10) for context,
+oldest→newest, mapped to `user`/`assistant` roles. History is NOT
+cached (it changes every turn); only the KB block is.
+
+### 7.4 Account isolation (critical)
+`prompt.ts` receives KB entries already filtered by `account_id`. A unit
+test asserts that entries from another account never appear in the
+assembled block. No global/shared KB in v1.
+
+## 8. Escalation & inbox UX
+
+- **Conversation list:** conversations with `ai_escalated_at` set (and
+ `status='pending'`) show a **"🙋 Needs human"** badge. Surfaces live
+ via the existing Supabase realtime subscription.
+- **Thread:** messages with `sender_type='bot'` render with a small
+ **"AI"** tag so agents see what the bot said.
+- **Controls:** a per-conversation **"Take over"** (sets
+ `ai_handling=false`) and **"Hand back to AI"** (sets
+ `ai_handling=true`, clears escalation fields) toggle. Sending a manual
+ reply implicitly takes over.
+- No new conversation status value introduced; reuse `pending`.
+
+## 9. Settings → "AI Assistant" tab
+
+Plugs into the existing settings rail (`settings-sections.ts` +
+`settings-rail.tsx`). Gated by `canEditSettings(role)` (admin+), mirror
+`whatsapp-config.tsx` / `template-manager.tsx` patterns.
+
+Sections:
+1. **Status & enable** — master toggle; shows whether `ANTHROPIC_API_KEY`
+ is configured (server-reported boolean, never the key).
+2. **Prompt** — textarea for `system_prompt` (seeded default), handoff
+ message, escalation-keyword chips.
+3. **Persona** — business name, logo upload (existing storage helper).
+4. **Knowledge base manager** — list entries (title, size, enabled
+ toggle), add/edit (title + markdown content), delete, and **file
+ upload** (`.txt`/`.md` read directly; PDF → text extraction). A
+ **size meter** shows total estimated tokens vs. a soft budget
+ (e.g. 150k) with a warning band — the signal to enable RAG later.
+5. **Model** — dropdown (Sonnet 4.6 / Haiku 4.5), daily reply cap input.
+
+## 10. API routes (account-scoped, admin+ via `requireRole`)
+
+| Route | Methods | Purpose |
+|---|---|---|
+| `src/app/api/ai/config/route.ts` | GET, PUT | Read/update `ai_assistant_config` |
+| `src/app/api/ai/knowledge/route.ts` | GET, POST | List / create KB entries |
+| `src/app/api/ai/knowledge/[id]/route.ts` | PATCH, DELETE | Edit / delete a KB entry |
+| `src/app/api/ai/knowledge/upload/route.ts` | POST | Accept a file, extract text, create an entry |
+
+All use the user-session Supabase server client (RLS enforces tenancy)
+plus an explicit role check. The conversation hand-off toggle reuses /
+extends the existing conversation-update path used by the inbox.
+
+## 11. Environment & config
+
+Add to `.env.local.example` (and document in the CI dummy-env block):
+```
+# AI assistant (Anthropic). Leave blank to disable AI replies.
+ANTHROPIC_API_KEY=
+# Optional override of the default model.
+AI_DEFAULT_MODEL=claude-sonnet-4-6
+```
+`anthropic.ts` reads `ANTHROPIC_API_KEY` lazily (like the Supabase admin
+clients) so a missing key never crashes the build — it just means AI is
+effectively off (treated as `enabled=false` / escalate).
+
+## 12. Security & safety
+
+- API key server-side only; never sent to the client; Settings shows
+ only a configured/not-configured boolean.
+- RLS on every new table; admin+ for config and KB; `ai_reply_log`
+ inserts are service-role only, reads admin-only.
+- **Strict per-account KB isolation** in prompt assembly (unit-tested).
+- **Fail safe to human**: missing key, API error, timeout, cap reached,
+ or low confidence all escalate; the customer is never left silent and
+ the bot never guesses.
+- No CSP change: all Anthropic calls are server-side (mirrors how Meta
+ calls already work).
+- The bot cannot perform destructive actions — it only sends a text
+ reply or escalates. No tool access to the DB beyond logging.
+
+## 13. Cost controls
+
+- **Prompt caching** on the KB block (primary lever).
+- **Daily reply cap** per account (escalate when exceeded).
+- History window capped (default 10 messages).
+- `ai_reply_log` records token usage per call for a future cost view.
+
+## 14. Testing
+
+Vitest, matching the `src/lib/**/*.test.ts` convention.
+
+- `prompt.test.ts` — KB assembly, cache_control placement, account
+ isolation, history mapping/order, history cap.
+- `guardrails.test.ts` — keyword matching (case/word-boundary), explicit
+ human-request detection, empty-KB behavior.
+- `decide.test.ts` — confident+answer → reply; not-confident → escalate;
+ empty answer despite confident → escalate; reason propagation.
+- `reply.test.ts` — orchestration with a mocked `anthropic` + mocked DB:
+ disabled→skip, human-owned→skip, keyword→escalate (no LLM call),
+ cap→escalate, error→escalate, happy path→send+log.
+- Anthropic SDK and Supabase admin client are injected/mocked; no
+ network in tests.
+
+CI (`.github/workflows/ci.yml`) gains a dummy `ANTHROPIC_API_KEY` in the
+env block so `next build` and module-load are satisfied (mirrors the
+existing `META_APP_SECRET` pattern).
+
+## 15. Build orchestration (workflow + worktrees)
+
+Implementation runs as a **Workflow** inside a dedicated **git
+worktree** on branch `feat/ai-assistant`, so it never disturbs the main
+checkout and lands as one reviewable branch.
+
+Phased pipeline (dependency-ordered; later phases consume earlier
+outputs):
+
+1. **Schema** — write `027_ai_assistant.sql` + extend `src/types`.
+2. **Lib core** (parallelizable once types exist) — the pure modules
+ (`prompt`, `guardrails`, `decide`) **with their tests written first**,
+ then `anthropic`, `config`, `knowledge-base`, `send`, `escalate`,
+ `reply`.
+3. **Webhook integration** — wire `maybeReplyToInbound` into
+ `processMessage`; set `ai_handling=false` in the send route.
+4. **API routes** — config + knowledge CRUD + upload.
+5. **UI** — Settings "AI Assistant" tab + KB manager; inbox badge, AI
+ tag, take-over/hand-back controls.
+6. **Verify** — `npm run lint && npm run typecheck && npm test &&
+ npm run build`; iterate to green.
+7. **Review** — adversarial review pass (security + correctness) before
+ opening a PR.
+
+Agents that mutate overlapping files run sequentially; independent files
+(e.g. the three pure libs, the API route files) fan out in parallel.
+Every agent is told to **read the relevant `node_modules/next/dist/docs/`
+guide before writing Next.js code** (per `AGENTS.md` — this is a modified
+Next.js 16) and to follow existing patterns in the files referenced
+above.
+
+## 16. Future / upgrade path
+
+- **RAG:** when the KB size meter trips the budget, add
+ `knowledge_base_chunks` (pgvector) + an embedding step; `prompt.ts`
+ swaps "whole KB" for "top-k retrieved chunks". Nothing else in the
+ flow changes — `decide`, guardrails, escalation, logging are reused.
+- **Flows node:** expose `maybeReplyToInbound` as an "AI Reply" node so
+ it can be composed visually.
+- **Cost dashboard:** read `ai_reply_log` into the existing dashboard.
From 480b74a17f2afc2c43bb7e421c2f50025a749f73 Mon Sep 17 00:00:00 2001
From: Martinxmaina
Date: Thu, 25 Jun 2026 19:13:52 +0300
Subject: [PATCH 2/8] =?UTF-8?q?feat(ai):=20WhatsApp=20AI=20assistant=20bac?=
=?UTF-8?q?kend=20=E2=80=94=20KB-grounded=20auto-reply=20+=20escalation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the customer-facing AI assistant engine (spec §1–§7, §12–§14):
- migration 027: ai_assistant_config, knowledge_base_entries,
ai_reply_log, and conversations.ai_handling/ai_escalated_at/
ai_escalation_reason (account-scoped, RLS, is_account_member gating)
- src/lib/ai/: pure prompt/guardrails/decide (+ tests), anthropic
forced-tool wrapper, config/knowledge-base/admin-client data access,
send (sender_type='bot') + escalate, and the maybeReplyToInbound
orchestrator (+ mocked tests)
- webhook: fire-and-forget AI dispatch gated on !flowConsumed
- send route: manual agent reply sets ai_handling=false (human takeover)
- types + ANTHROPIC_API_KEY / unpdf deps
Fail-safe to human on any doubt/error; autonomous send only when the
model self-reports confidence and guardrails + daily cap pass.
typecheck/lint/test(526)/build all green.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
package-lock.json | 89 ++++-
package.json | 4 +-
src/app/api/whatsapp/send/route.ts | 7 +-
src/app/api/whatsapp/webhook/route.ts | 69 +++-
src/lib/ai/admin-client.ts | 16 +
src/lib/ai/anthropic.ts | 247 ++++++++++++
src/lib/ai/config.ts | 43 +++
src/lib/ai/decide.test.ts | 145 +++++++
src/lib/ai/decide.ts | 65 ++++
src/lib/ai/escalate.ts | 143 +++++++
src/lib/ai/guardrails.test.ts | 155 ++++++++
src/lib/ai/guardrails.ts | 184 +++++++++
src/lib/ai/knowledge-base.ts | 78 ++++
src/lib/ai/prompt.test.ts | 315 +++++++++++++++
src/lib/ai/prompt.ts | 187 +++++++++
src/lib/ai/reply.test.ts | 470 +++++++++++++++++++++++
src/lib/ai/reply.ts | 386 +++++++++++++++++++
src/lib/ai/send.ts | 146 +++++++
src/types/index.ts | 153 ++++++++
supabase/migrations/027_ai_assistant.sql | 214 +++++++++++
20 files changed, 3092 insertions(+), 24 deletions(-)
create mode 100644 src/lib/ai/admin-client.ts
create mode 100644 src/lib/ai/anthropic.ts
create mode 100644 src/lib/ai/config.ts
create mode 100644 src/lib/ai/decide.test.ts
create mode 100644 src/lib/ai/decide.ts
create mode 100644 src/lib/ai/escalate.ts
create mode 100644 src/lib/ai/guardrails.test.ts
create mode 100644 src/lib/ai/guardrails.ts
create mode 100644 src/lib/ai/knowledge-base.ts
create mode 100644 src/lib/ai/prompt.test.ts
create mode 100644 src/lib/ai/prompt.ts
create mode 100644 src/lib/ai/reply.test.ts
create mode 100644 src/lib/ai/reply.ts
create mode 100644 src/lib/ai/send.ts
create mode 100644 supabase/migrations/027_ai_assistant.sql
diff --git a/package-lock.json b/package-lock.json
index 7de68320f7..3458154a33 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.2.2",
"license": "MIT",
"dependencies": {
+ "@anthropic-ai/sdk": "^0.106.0",
"@base-ui/react": "^1.5.0",
"@dagrejs/dagre": "^3.0.0",
"@dnd-kit/core": "^6.3.1",
@@ -29,7 +30,8 @@
"shadcn": "^4.11.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "unpdf": "^1.6.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -61,6 +63,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@anthropic-ai/sdk": {
+ "version": "0.106.0",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.106.0.tgz",
+ "integrity": "sha512-ufwVvYNDBj2dzOGupBCTaNzBLxqcTnGOzI4z8Wouxlt+mT3J3HuOmatgCy1VmwCHOUueqZ41ERhm0O99OUcbWA==",
+ "license": "MIT",
+ "dependencies": {
+ "json-schema-to-ts": "^3.1.1",
+ "standardwebhooks": "^1.0.0"
+ },
+ "bin": {
+ "anthropic-ai-sdk": "bin/cli"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -2248,6 +2271,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2951,7 +2980,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2961,7 +2990,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -4526,7 +4555,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -5900,6 +5929,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
@@ -7293,6 +7328,19 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
+ "node_modules/json-schema-to-ts": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
+ "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "ts-algebra": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -8850,6 +8898,7 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
@@ -9654,6 +9703,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/standardwebhooks": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+ "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "fast-sha256": "^1.3.0"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -10080,6 +10139,12 @@
"node": ">=0.6"
}
},
+ "node_modules/ts-algebra": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
+ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
+ "license": "MIT"
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -10253,7 +10318,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -10334,6 +10399,20 @@
"node": ">= 10.0.0"
}
},
+ "node_modules/unpdf": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/unpdf/-/unpdf-1.6.2.tgz",
+ "integrity": "sha512-zQ80ySoPuPHOsvIoRp/nJyQt8TOUoTh1+WBCGcBvlddQNgKDLRwm0AY3x8Q35I7+kIiRSgqMx+Ma2pl9McIp7A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@napi-rs/canvas": "^0.1.69"
+ },
+ "peerDependenciesMeta": {
+ "@napi-rs/canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
diff --git a/package.json b/package.json
index 20d01a2493..2f9a3c8455 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"test:watch": "vitest"
},
"dependencies": {
+ "@anthropic-ai/sdk": "^0.106.0",
"@base-ui/react": "^1.5.0",
"@dagrejs/dagre": "^3.0.0",
"@dnd-kit/core": "^6.3.1",
@@ -59,7 +60,8 @@
"shadcn": "^4.11.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "unpdf": "^1.6.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts
index 107c493257..6ab574aed8 100644
--- a/src/app/api/whatsapp/send/route.ts
+++ b/src/app/api/whatsapp/send/route.ts
@@ -388,13 +388,18 @@ export async function POST(request: Request) {
)
}
- // Update conversation
+ // Update conversation. Setting `ai_handling=false` is the human-
+ // takeover signal: an agent replying by hand owns the conversation
+ // from here on, so the AI assistant goes silent on it immediately
+ // (mirrors the flow pause below — the strongest "human is here"
+ // signal). "Hand back to AI" in the inbox flips this back to true.
await supabase
.from('conversations')
.update({
last_message_text: content_text || `[${message_type}]`,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
+ ai_handling: false,
})
.eq('id', conversation_id)
diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts
index ba404f83e7..b5ecddc30e 100644
--- a/src/app/api/whatsapp/webhook/route.ts
+++ b/src/app/api/whatsapp/webhook/route.ts
@@ -7,6 +7,7 @@ import { findExistingContact, isUniqueViolation } from '@/lib/contacts/dedupe'
import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature'
import { runAutomationsForTrigger } from '@/lib/automations/engine'
import { dispatchInboundToFlows } from '@/lib/flows/engine'
+import { maybeReplyToInbound } from '@/lib/ai/reply'
import {
handleTemplateWebhookChange,
isTemplateWebhookField,
@@ -275,7 +276,10 @@ async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) {
// inserts that need it for NOT NULL FK compliance. Always
// the admin who saved the WhatsApp config.
config.user_id,
- decryptedAccessToken
+ decryptedAccessToken,
+ // Meta phone-number id — the AI assistant needs it to send an
+ // autonomous reply via the same Meta send path.
+ config.phone_number_id
)
}
}
@@ -509,7 +513,11 @@ async function processMessage(
// (contacts, conversations). Always the admin who saved the
// WhatsApp config; the choice is arbitrary post-017 but stable.
configOwnerUserId: string,
- accessToken: string
+ accessToken: string,
+ // Meta phone-number id from the matched whatsapp_config row. Passed
+ // through to the AI assistant so an autonomous reply goes out on the
+ // same number the inbound arrived on.
+ phoneNumberId: string
) {
const senderPhone = normalizePhone(message.from)
const contactName = contact.profile.name
@@ -595,21 +603,28 @@ async function processMessage(
.eq('sender_type', 'customer')
const isFirstInboundMessage = (priorCustomerMsgCount ?? 0) === 0
- const { error: msgError } = await supabaseAdmin().from('messages').insert({
- conversation_id: conversation.id,
- sender_type: 'customer',
- content_type: contentType,
- content_text: contentText,
- media_url: mediaUrl,
- message_id: message.id,
- status: 'delivered',
- created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(),
- reply_to_message_id: replyToInternalId,
- // Only populated for content_type='interactive'. Migration 010 added
- // the column; null for every other content_type so existing inserts
- // behave identically.
- interactive_reply_id: interactiveReplyId,
- })
+ const { data: insertedMessage, error: msgError } = await supabaseAdmin()
+ .from('messages')
+ .insert({
+ conversation_id: conversation.id,
+ sender_type: 'customer',
+ content_type: contentType,
+ content_text: contentText,
+ media_url: mediaUrl,
+ message_id: message.id,
+ status: 'delivered',
+ created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(),
+ reply_to_message_id: replyToInternalId,
+ // Only populated for content_type='interactive'. Migration 010 added
+ // the column; null for every other content_type so existing inserts
+ // behave identically.
+ interactive_reply_id: interactiveReplyId,
+ })
+ // `.select().single()` so the inserted UUID is available for the AI
+ // assistant's `ai_reply_log` audit row (the inbound message that
+ // triggered the decision — see maybeReplyToInbound dispatch below).
+ .select('id')
+ .single()
if (msgError) {
console.error('Error inserting message:', msgError)
@@ -713,6 +728,26 @@ async function processMessage(
},
}).catch((err) => console.error('[automations] dispatch failed:', err))
}
+
+ // AI assistant dispatch. Suppressed when a flow consumed the message —
+ // the customer is navigating a bot menu/flow, so the assistant must not
+ // also reply (same `!flowConsumed` gate as the content-level automation
+ // triggers above). Fire-and-forget like the automation dispatch:
+ // maybeReplyToInbound has its own try/catch, never throws, and runs off
+ // the webhook's ack path so a slow or failing AI call can't delay Meta's
+ // 200. Its own internal gates (enabled / ai_handling / guardrails / cap)
+ // decide whether it actually replies or escalates.
+ if (!flowConsumed) {
+ maybeReplyToInbound({
+ accountId,
+ conversationId: conversation.id,
+ contactId: contactRecord.id,
+ messageId: insertedMessage.id,
+ inboundText,
+ accessToken,
+ phoneNumberId,
+ }).catch(console.error)
+ }
}
async function parseMessageContent(
diff --git a/src/lib/ai/admin-client.ts b/src/lib/ai/admin-client.ts
new file mode 100644
index 0000000000..6bd23ad6c6
--- /dev/null
+++ b/src/lib/ai/admin-client.ts
@@ -0,0 +1,16 @@
+import { createClient, type SupabaseClient } from '@supabase/supabase-js'
+
+// Lazy, shared service-role client for the AI assistant engine.
+// Mirrors src/lib/flows/admin-client.ts — same shape so anyone
+// reading either file picks up the convention immediately.
+let _adminClient: SupabaseClient | null = null
+
+export function supabaseAdmin(): SupabaseClient {
+ if (!_adminClient) {
+ _adminClient = createClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.SUPABASE_SERVICE_ROLE_KEY!,
+ )
+ }
+ return _adminClient
+}
diff --git a/src/lib/ai/anthropic.ts b/src/lib/ai/anthropic.ts
new file mode 100644
index 0000000000..7458211b8a
--- /dev/null
+++ b/src/lib/ai/anthropic.ts
@@ -0,0 +1,247 @@
+/**
+ * Thin wrapper around `@anthropic-ai/sdk` (spec §5/§7.1, §11).
+ *
+ * The only place in `src/lib/ai/` that talks to Anthropic. It forces a
+ * single `submit_answer` tool so the model's decision comes back as a
+ * structured `tool_use` block (no JSON-in-text scraping — spec §7.1),
+ * parses that block into the canonical `AiModelResult`, and surfaces the
+ * token `usage` the orchestrator records in `ai_reply_log`.
+ *
+ * Deliberately thin: prompt assembly lives in `prompt.ts`, the
+ * reply/escalate verdict in `decide.ts`, orchestration in `reply.ts`.
+ * This module just does the network round-trip and the parse, so it is
+ * trivially mockable — `callAssistant` accepts an injected `client`
+ * (default a real `Anthropic` instance) and the reply tests pass a stub
+ * instead of hitting the network (spec §14).
+ *
+ * The API key is read LAZILY (spec §11): the SDK is constructed only on
+ * first real call, so a missing `ANTHROPIC_API_KEY` never crashes
+ * `next build` or module load — it just means AI is effectively off. A
+ * missing key throws the typed `MissingApiKeyError`; the orchestrator's
+ * try/catch turns that (like any other failure) into a fail-safe
+ * escalation to a human (spec §6, §12).
+ */
+
+import Anthropic from "@anthropic-ai/sdk";
+
+import { type AiModelResult } from "@/types";
+
+/** The forced tool's name (spec §7.1). */
+export const SUBMIT_ANSWER_TOOL_NAME = "submit_answer";
+
+/**
+ * Thrown when `ANTHROPIC_API_KEY` is absent at call time. Typed (not a
+ * bare `Error`) so callers can distinguish "AI is not configured" from a
+ * transient API failure if they ever need to — and so it reads clearly
+ * in the audit log. Mirrors the typed-error convention in
+ * `src/lib/auth/account.ts`.
+ */
+export class MissingApiKeyError extends Error {
+ constructor(message = "ANTHROPIC_API_KEY is not configured") {
+ super(message);
+ this.name = "MissingApiKeyError";
+ }
+}
+
+/**
+ * Token usage from the Anthropic `usage` block, projected to the three
+ * counts the audit log cares about (spec §4.4 / §13). The SDK reports
+ * `input_tokens` / `cache_read_input_tokens` as nullable; we normalise
+ * `null` to `0` so the caller always gets numbers.
+ */
+export interface AiUsage {
+ input_tokens: number;
+ output_tokens: number;
+ cache_read_input_tokens: number;
+}
+
+/** Arguments for {@link callAssistant}. */
+export interface CallAssistantArgs {
+ /** Anthropic model id (e.g. `claude-sonnet-4-6`) — from account config. */
+ model: string;
+ /**
+ * The `system` array built by `prompt.buildSystemBlocks` (persona +
+ * cache-marked KB block). Passed straight through.
+ */
+ system: Anthropic.TextBlockParam[];
+ /**
+ * The `messages` array built by `prompt.buildMessages` (history +
+ * inbound). Passed straight through.
+ */
+ messages: Anthropic.MessageParam[];
+ /**
+ * Optional injected client (default a real `Anthropic` instance, built
+ * lazily). Tests pass a stub so no network call happens (spec §14).
+ */
+ client?: Pick;
+}
+
+/** The wrapper's result: the model's verdict plus the token usage. */
+export interface CallAssistantResult extends AiModelResult {
+ usage: AiUsage;
+}
+
+/**
+ * Cap on the model's output. The `submit_answer` payload is small (a
+ * single support reply plus a short rationale), so this is generous —
+ * it bounds runaway generation without truncating a normal reply.
+ */
+const MAX_OUTPUT_TOKENS = 1024;
+
+/**
+ * The single tool the model is forced to call (spec §7.1). Its
+ * `input_schema` is the structured decision contract: the reply to send,
+ * a strict confidence flag, and a short rationale for the audit log.
+ */
+const SUBMIT_ANSWER_TOOL: Anthropic.Tool = {
+ name: SUBMIT_ANSWER_TOOL_NAME,
+ description:
+ "Submit your decision for this customer message. Call this exactly " +
+ "once. Set `confident` to true ONLY if the knowledge base fully and " +
+ "clearly answers the question; otherwise set it to false and leave " +
+ "`answer` empty so the conversation is handed to a human.",
+ input_schema: {
+ type: "object",
+ properties: {
+ answer: {
+ type: "string",
+ description:
+ "The reply to send the customer. Empty string if you are not " +
+ "confident the knowledge base answers the question.",
+ },
+ confident: {
+ type: "boolean",
+ description:
+ "True ONLY if the knowledge base fully answers the question.",
+ },
+ reason: {
+ type: "string",
+ description: "Short rationale for the decision, for the audit log.",
+ },
+ },
+ required: ["answer", "confident", "reason"],
+ },
+};
+
+/**
+ * Force Claude to answer through the single `submit_answer` tool
+ * (spec §7.1).
+ */
+const SUBMIT_ANSWER_TOOL_CHOICE: Anthropic.ToolChoiceTool = {
+ type: "tool",
+ name: SUBMIT_ANSWER_TOOL_NAME,
+};
+
+/** Process-wide lazily-constructed real client. Reused across calls. */
+let _client: Anthropic | null = null;
+
+/**
+ * Lazily construct (and memoise) the real Anthropic client.
+ *
+ * Reads `ANTHROPIC_API_KEY` at CALL TIME, not module load (spec §11): a
+ * missing key throws {@link MissingApiKeyError} here rather than crashing
+ * the build. The key is passed explicitly so the failure mode is our
+ * typed error, not the SDK's own "missing key" throw.
+ */
+function getClient(): Anthropic {
+ const apiKey = process.env.ANTHROPIC_API_KEY;
+ if (!apiKey || apiKey.trim().length === 0) {
+ throw new MissingApiKeyError();
+ }
+ if (!_client) {
+ _client = new Anthropic({ apiKey });
+ }
+ return _client;
+}
+
+/**
+ * Coerce the SDK's nullable usage counts into a plain numeric
+ * {@link AiUsage}. `input_tokens` / `cache_read_input_tokens` can be
+ * `null`; normalise to `0`.
+ */
+function toUsage(usage: Anthropic.Usage): AiUsage {
+ return {
+ input_tokens: usage.input_tokens ?? 0,
+ output_tokens: usage.output_tokens ?? 0,
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
+ };
+}
+
+/**
+ * Extract and validate the `submit_answer` payload from the response
+ * content. Throws if no matching `tool_use` block is present or its input
+ * is not the expected shape — the orchestrator's try/catch turns that
+ * into a fail-safe escalation rather than sending a malformed reply.
+ */
+function parseToolUse(content: Anthropic.ContentBlock[]): AiModelResult {
+ const block = content.find(
+ (b): b is Anthropic.ToolUseBlock =>
+ b.type === "tool_use" && b.name === SUBMIT_ANSWER_TOOL_NAME,
+ );
+
+ if (!block) {
+ throw new Error(
+ `Anthropic response contained no '${SUBMIT_ANSWER_TOOL_NAME}' tool_use block`,
+ );
+ }
+
+ const input = block.input;
+ if (typeof input !== "object" || input === null) {
+ throw new Error(
+ `'${SUBMIT_ANSWER_TOOL_NAME}' tool_use input was not an object`,
+ );
+ }
+
+ const { answer, confident, reason } = input as Record;
+ if (
+ typeof answer !== "string" ||
+ typeof confident !== "boolean" ||
+ typeof reason !== "string"
+ ) {
+ throw new Error(
+ `'${SUBMIT_ANSWER_TOOL_NAME}' tool_use input did not match the expected schema`,
+ );
+ }
+
+ return { answer, confident, reason };
+}
+
+/**
+ * Call Claude for a single customer message and return its structured
+ * decision plus token usage (spec §7.1).
+ *
+ * Forces the `submit_answer` tool via
+ * `tool_choice: { type: 'tool', name: 'submit_answer' }`, so the model
+ * MUST respond with one `tool_use` block whose `input` is
+ * `{ answer, confident, reason }`. The block is parsed and validated;
+ * anything unexpected (missing block, wrong shape) throws so the caller
+ * escalates instead of guessing.
+ *
+ * Pass `client` to inject a stub in tests; omit it to use the real,
+ * lazily-built client (which reads `ANTHROPIC_API_KEY` and throws
+ * {@link MissingApiKeyError} if it is absent).
+ */
+export async function callAssistant({
+ model,
+ system,
+ messages,
+ client,
+}: CallAssistantArgs): Promise {
+ const anthropic = client ?? getClient();
+
+ const response = await anthropic.messages.create({
+ model: model as Anthropic.Model,
+ max_tokens: MAX_OUTPUT_TOKENS,
+ system,
+ messages,
+ tools: [SUBMIT_ANSWER_TOOL],
+ tool_choice: SUBMIT_ANSWER_TOOL_CHOICE,
+ });
+
+ const result = parseToolUse(response.content);
+
+ return {
+ ...result,
+ usage: toUsage(response.usage),
+ };
+}
diff --git a/src/lib/ai/config.ts b/src/lib/ai/config.ts
new file mode 100644
index 0000000000..8895b13392
--- /dev/null
+++ b/src/lib/ai/config.ts
@@ -0,0 +1,43 @@
+/**
+ * Load the per-account AI assistant configuration (spec §4.1, §5).
+ *
+ * A thin service-role read of the single `ai_assistant_config` row for an
+ * account. The webhook path runs as service-role and bypasses RLS exactly
+ * as the Flows and automations engines already do (spec §4 / §12).
+ *
+ * Returns `null` when no config row exists (the account never opened the
+ * AI Settings tab) or on any DB error — the caller treats a missing config
+ * as "AI off" and skips, which is the safe default (spec §6: `!enabled →
+ * skip`, and the broader fail-safe-to-human bias of §1).
+ */
+
+import { type AiAssistantConfig } from "@/types";
+
+import { supabaseAdmin } from "./admin-client";
+
+/**
+ * Load `ai_assistant_config` for one account.
+ *
+ * `.maybeSingle()` returns `null` (not an error) when there is no row, so
+ * an account without a config simply reads as "no AI configured". The
+ * `account_id` column is UNIQUE in the DB (spec §4.1), so at most one row
+ * ever matches.
+ */
+export async function loadAiConfig(
+ accountId: string,
+): Promise {
+ const db = supabaseAdmin();
+
+ const { data, error } = await db
+ .from("ai_assistant_config")
+ .select("*")
+ .eq("account_id", accountId)
+ .maybeSingle();
+
+ if (error) {
+ console.error("[ai] loadAiConfig error:", error.message);
+ return null;
+ }
+
+ return (data as AiAssistantConfig | null) ?? null;
+}
diff --git a/src/lib/ai/decide.test.ts b/src/lib/ai/decide.test.ts
new file mode 100644
index 0000000000..3b6cf184ed
--- /dev/null
+++ b/src/lib/ai/decide.test.ts
@@ -0,0 +1,145 @@
+import { describe, it, expect } from "vitest";
+
+import { decide } from "./decide";
+import { type AiModelResult } from "@/types";
+
+describe("decide — confident with an answer → reply", () => {
+ it("replies with the answer text when confident and answer is non-empty", () => {
+ const result: AiModelResult = {
+ answer: "We're open 9am–5pm, Monday to Friday.",
+ confident: true,
+ reason: "Opening hours are listed in the KB.",
+ };
+ expect(decide(result)).toEqual({
+ action: "reply",
+ text: "We're open 9am–5pm, Monday to Friday.",
+ });
+ });
+
+ it("passes the answer through verbatim (no trimming of the sent text)", () => {
+ const result: AiModelResult = {
+ answer: " Sure — here you go. ",
+ confident: true,
+ reason: "answered",
+ };
+ expect(decide(result)).toEqual({
+ action: "reply",
+ text: " Sure — here you go. ",
+ });
+ });
+
+ it("does not leak a `reason` onto a reply result", () => {
+ const decision = decide({
+ answer: "Yes, we ship to Kenya.",
+ confident: true,
+ reason: "shipping coverage is in the KB",
+ });
+ expect(decision).not.toHaveProperty("reason");
+ });
+});
+
+describe("decide — not confident → escalate", () => {
+ it("escalates with reason 'low_confidence' when confident is false", () => {
+ const result: AiModelResult = {
+ answer: "",
+ confident: false,
+ reason: "KB does not cover refund timelines.",
+ };
+ expect(decide(result)).toEqual({
+ action: "escalate",
+ reason: "low_confidence",
+ });
+ });
+
+ it("escalates when not confident even if an answer was supplied", () => {
+ // Belt-and-braces: a non-empty answer must NOT override a false
+ // self-report — the model said it wasn't sure, so we don't send it.
+ const result: AiModelResult = {
+ answer: "I think it's probably 30 days?",
+ confident: false,
+ reason: "guessing",
+ };
+ expect(decide(result)).toEqual({
+ action: "escalate",
+ reason: "low_confidence",
+ });
+ });
+});
+
+describe("decide — confident but empty answer → escalate", () => {
+ it("escalates when confident but the answer is empty", () => {
+ const result: AiModelResult = {
+ answer: "",
+ confident: true,
+ reason: "no content",
+ };
+ expect(decide(result)).toEqual({
+ action: "escalate",
+ reason: "low_confidence",
+ });
+ });
+
+ it("escalates when confident but the answer is whitespace-only", () => {
+ const result: AiModelResult = {
+ answer: " \n\t ",
+ confident: true,
+ reason: "blank",
+ };
+ expect(decide(result)).toEqual({
+ action: "escalate",
+ reason: "low_confidence",
+ });
+ });
+});
+
+describe("decide — malformed / degenerate input → escalate", () => {
+ it("escalates when confident is a truthy non-true value (strict check)", () => {
+ const result = {
+ answer: "Anything.",
+ confident: "yes",
+ reason: "string-not-boolean",
+ } as unknown as AiModelResult;
+ expect(decide(result)).toEqual({
+ action: "escalate",
+ reason: "low_confidence",
+ });
+ });
+
+ it("escalates when answer is a non-string value", () => {
+ const result = {
+ answer: 42,
+ confident: true,
+ reason: "answer-not-string",
+ } as unknown as AiModelResult;
+ expect(decide(result)).toEqual({
+ action: "escalate",
+ reason: "low_confidence",
+ });
+ });
+
+ it("escalates safely on a null / undefined model result", () => {
+ expect(decide(null as unknown as AiModelResult)).toEqual({
+ action: "escalate",
+ reason: "low_confidence",
+ });
+ expect(decide(undefined as unknown as AiModelResult)).toEqual({
+ action: "escalate",
+ reason: "low_confidence",
+ });
+ });
+});
+
+describe("decide — determinism / purity", () => {
+ it("returns the same result for the same input and does not mutate it", () => {
+ const result: AiModelResult = {
+ answer: "Hello!",
+ confident: true,
+ reason: "greeting",
+ };
+ const snapshot = { ...result };
+ const first = decide(result);
+ const second = decide(result);
+ expect(first).toEqual(second);
+ expect(result).toEqual(snapshot);
+ });
+});
diff --git a/src/lib/ai/decide.ts b/src/lib/ai/decide.ts
new file mode 100644
index 0000000000..ca278f141d
--- /dev/null
+++ b/src/lib/ai/decide.ts
@@ -0,0 +1,65 @@
+/**
+ * The decision core (spec §5/§6).
+ *
+ * The second, post-LLM half of the assistant's "fail safe to a human"
+ * bias. Once the model has answered via the forced `submit_answer` tool,
+ * `decide` reduces its structured `AiModelResult` to a single, parseable
+ * verdict: reply autonomously or escalate to a human.
+ *
+ * Per spec §6 the contract is deliberately strict — auto-send requires
+ * BOTH halves of the model's self-report to hold:
+ * - `confident === true` (the KB fully answers the question), AND
+ * - a non-empty `answer` (there is actually something to send).
+ *
+ * Anything else — not confident, or confident but with an empty/blank
+ * answer (a degenerate "confident-but-said-nothing" response) — escalates
+ * with `reason: 'low_confidence'`. The customer is never left silent and
+ * the bot never sends an empty message.
+ *
+ * Pure and fully deterministic: no I/O, no clock, no randomness, no
+ * mutation of the input. All the logic worth testing lives here so it can
+ * be exercised without a Supabase / Anthropic mock (mirrors
+ * `src/lib/ai/guardrails.ts` and `src/lib/flows/fallback.ts`).
+ */
+
+import { type AiEscalationReason, type AiModelResult } from "@/types";
+
+/** The two terminal actions the assistant can take on an inbound message. */
+export type DecideAction = "reply" | "escalate";
+
+/**
+ * The verdict produced from a model result.
+ *
+ * - `{ action: 'reply', text }` — send `text` to the customer as a `bot`
+ * message. `text` is always present and non-empty when `action` is
+ * `'reply'`.
+ * - `{ action: 'escalate', reason }` — hand the conversation to a human.
+ * `reason` is always the `'low_confidence'` escalation reason (the only
+ * post-LLM escalation `decide` itself produces; keyword / cap / error
+ * escalations are decided upstream). It maps straight onto
+ * `ai_reply_log.reason` / `conversations.ai_escalation_reason`.
+ */
+export type DecideResult =
+ | { action: "reply"; text: string }
+ | { action: "escalate"; reason: AiEscalationReason };
+
+/**
+ * Reduce the model's structured `submit_answer` result to a reply /
+ * escalate decision.
+ *
+ * Returns `{ action: 'reply', text: answer }` ONLY when the model both
+ * reported `confident === true` and supplied a non-empty (non-whitespace)
+ * `answer`. Every other case — not confident, missing/blank answer, or a
+ * malformed result object — escalates with `reason: 'low_confidence'`,
+ * honouring the "any doubt → escalate, never send an empty reply" bias.
+ */
+export function decide(modelResult: AiModelResult): DecideResult {
+ const confident = modelResult?.confident === true;
+ const answer = typeof modelResult?.answer === "string" ? modelResult.answer : "";
+
+ if (confident && answer.trim().length > 0) {
+ return { action: "reply", text: answer };
+ }
+
+ return { action: "escalate", reason: "low_confidence" };
+}
diff --git a/src/lib/ai/escalate.ts b/src/lib/ai/escalate.ts
new file mode 100644
index 0000000000..bfb963aac2
--- /dev/null
+++ b/src/lib/ai/escalate.ts
@@ -0,0 +1,143 @@
+/**
+ * Conversation escalation (spec §5: `escalate.ts`, §6, §8).
+ *
+ * The single place the AI hands a conversation to a human. Called from the
+ * orchestrator (`reply.ts`) for every non-reply outcome — keyword match,
+ * daily-cap hit, low confidence, or any thrown error — honouring the
+ * "fail safe to a human" bias of spec §1.
+ *
+ * It does two things, in this order:
+ *
+ * 1. Flips the conversation out of AI control via the service-role admin
+ * client (RLS-bypassing, like the rest of the AI/Flows engine):
+ * - `ai_handling = false` → AI goes silent on this thread
+ * - `ai_escalated_at = now()` → drives the "🙋 Needs human" badge
+ * - `ai_escalation_reason = reason` → audit + inbox surfacing
+ * - `status = 'pending'` → reuses an existing status value
+ * that reads as "needs attention"
+ * (spec §4.3, §8 — no new status value is introduced.)
+ *
+ * 2. If `config.handoff_message` is set, sends it to the customer over the
+ * SAME Meta path the inbox composer + Flows engine use
+ * (`sendTextMessage` from `src/lib/whatsapp/meta-api.ts`), so the
+ * customer is never left silent.
+ *
+ * The DB flip is the load-bearing safety step and runs FIRST: if the
+ * optional handoff send fails, the conversation is still correctly escalated
+ * and silent. The handoff send is therefore best-effort — its failure is
+ * logged, not thrown — so a Meta hiccup never leaves the AI "handling" a
+ * conversation it has already decided to abandon.
+ *
+ * Note: the handoff send does NOT persist a `messages` row. It is a system
+ * notice to the customer, not part of the agent/bot transcript; keeping it
+ * out of `messages` avoids it reading as an AI reply (which carries the
+ * `sender_type='bot'` "AI" tag) once a human has taken over.
+ */
+
+import {
+ sanitizePhoneForMeta,
+ isValidE164,
+ phoneVariants,
+ isRecipientNotAllowedError,
+} from '@/lib/whatsapp/phone-utils'
+import { sendTextMessage } from '@/lib/whatsapp/meta-api'
+import { type AiAssistantConfig, type AiEscalationReason } from '@/types'
+
+import { supabaseAdmin } from './admin-client'
+
+export interface EscalateConversationArgs {
+ /** Conversation to hand to a human. */
+ conversationId: string
+ /** Why we escalated — written to `conversations.ai_escalation_reason`. */
+ reason: AiEscalationReason
+ /**
+ * The account's AI config. Only `handoff_message` is consulted here; the
+ * full object is passed so the caller doesn't have to re-shape it.
+ */
+ config: AiAssistantConfig
+ /** Decrypted Meta access token, resolved by the caller. */
+ accessToken: string
+ /** Meta phone-number id, resolved by the caller. */
+ phoneNumberId: string
+ /** The customer's phone number, to send the optional handoff message to. */
+ customerPhone: string
+}
+
+/**
+ * Escalate a conversation to a human and optionally notify the customer.
+ *
+ * Always flips the conversation's AI hand-off columns; sends
+ * `config.handoff_message` to the customer only when it is set. Never
+ * throws on a handoff-send failure — the escalation itself is what must
+ * succeed (spec §1, §6).
+ */
+export async function escalateConversation(
+ args: EscalateConversationArgs,
+): Promise {
+ const db = supabaseAdmin()
+
+ // 1. Flip the conversation out of AI control. This is the safety-critical
+ // step — do it first so a later handoff-send failure can't leave the
+ // AI still "handling" a conversation it has abandoned.
+ const now = new Date().toISOString()
+ const { error: updateErr } = await db
+ .from('conversations')
+ .update({
+ ai_handling: false,
+ ai_escalated_at: now,
+ ai_escalation_reason: args.reason,
+ status: 'pending',
+ updated_at: now,
+ })
+ .eq('id', args.conversationId)
+ if (updateErr) {
+ // The DB flip is load-bearing; surface its failure so the caller logs
+ // an `error` decision and a human can be alerted another way.
+ throw new Error(`escalation DB update failed: ${updateErr.message}`)
+ }
+
+ // 2. Optionally notify the customer. Nothing to send when no handoff
+ // message is configured (nullable = send nothing, spec §4.1).
+ const handoff = args.config.handoff_message?.trim()
+ if (!handoff) return
+
+ const sanitized = sanitizePhoneForMeta(args.customerPhone)
+ if (!isValidE164(sanitized)) {
+ // Can't message an invalid number — the conversation is still correctly
+ // escalated, so just log and return rather than throwing.
+ console.error(
+ `[ai] escalate: customer phone invalid, skipping handoff send: ${args.customerPhone}`,
+ )
+ return
+ }
+
+ // Best-effort handoff send over the same Meta path as the composer / flows
+ // engine, with the same phone-variant retry. A failure here is logged, not
+ // thrown — the escalation has already taken effect.
+ try {
+ const variants = phoneVariants(sanitized)
+ let lastError: unknown = null
+ for (const v of variants) {
+ try {
+ await sendTextMessage({
+ phoneNumberId: args.phoneNumberId,
+ accessToken: args.accessToken,
+ to: v,
+ text: handoff,
+ })
+ lastError = null
+ break
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err)
+ if (!isRecipientNotAllowedError(msg)) throw err
+ lastError = err
+ }
+ }
+ if (lastError) throw lastError
+ } catch (err) {
+ console.error(
+ '[ai] escalate: handoff message send failed:',
+ err instanceof Error ? err.message : err,
+ )
+ }
+}
diff --git a/src/lib/ai/guardrails.test.ts b/src/lib/ai/guardrails.test.ts
new file mode 100644
index 0000000000..c2cd6fce89
--- /dev/null
+++ b/src/lib/ai/guardrails.test.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it } from "vitest";
+
+import { shouldForceEscalate } from "./guardrails";
+
+// The seeded default keyword list from the spec (§4.1 / §7).
+const DEFAULT_KEYWORDS = [
+ "refund",
+ "cancel",
+ "complaint",
+ "lawyer",
+ "legal",
+ "human",
+ "agent",
+ "manager",
+];
+
+describe("shouldForceEscalate — configured keywords", () => {
+ it("escalates with reason 'keyword' on a whole-word match", () => {
+ expect(
+ shouldForceEscalate("I want a refund please", DEFAULT_KEYWORDS),
+ ).toEqual({ escalate: true, reason: "keyword" });
+ });
+
+ it("matches case-insensitively", () => {
+ expect(shouldForceEscalate("REFUND NOW", DEFAULT_KEYWORDS).escalate).toBe(
+ true,
+ );
+ expect(
+ shouldForceEscalate("Filing a CoMpLaInT", DEFAULT_KEYWORDS).escalate,
+ ).toBe(true);
+ });
+
+ it("anchors on word boundaries — no substring false positives", () => {
+ // "legal" must not fire inside "illegal".
+ expect(
+ shouldForceEscalate("Is this illegal?", DEFAULT_KEYWORDS).escalate,
+ ).toBe(false);
+ // "cancel" must not fire inside "cancellation" boundaries that are
+ // still distinct words — but it DOES fire as its own word.
+ expect(
+ shouldForceEscalate("scancelled the order", DEFAULT_KEYWORDS).escalate,
+ ).toBe(false);
+ // "agent" must not fire inside "agentic".
+ expect(
+ shouldForceEscalate("an agentic workflow", DEFAULT_KEYWORDS).escalate,
+ ).toBe(false);
+ });
+
+ it("matches a keyword adjacent to punctuation", () => {
+ expect(
+ shouldForceEscalate("refund, now!", DEFAULT_KEYWORDS).escalate,
+ ).toBe(true);
+ expect(
+ shouldForceEscalate("Where is my refund?", DEFAULT_KEYWORDS).escalate,
+ ).toBe(true);
+ });
+
+ it("does not escalate on unrelated text", () => {
+ expect(
+ shouldForceEscalate("What are your opening hours?", DEFAULT_KEYWORDS),
+ ).toEqual({ escalate: false });
+ });
+
+ it("matches a multi-word configured keyword across any whitespace", () => {
+ expect(
+ shouldForceEscalate("I have a billing dispute", ["billing dispute"])
+ .escalate,
+ ).toBe(true);
+ });
+});
+
+describe("shouldForceEscalate — empty / malformed inputs", () => {
+ it("does not escalate when the keyword list is empty (no explicit request)", () => {
+ expect(shouldForceEscalate("what are your hours?", [])).toEqual({
+ escalate: false,
+ });
+ });
+
+ it("ignores blank / whitespace-only keywords (no match-everything bug)", () => {
+ expect(shouldForceEscalate("hello there", ["", " "])).toEqual({
+ escalate: false,
+ });
+ });
+
+ it("returns no-escalate for empty or whitespace-only inbound text", () => {
+ expect(shouldForceEscalate("", DEFAULT_KEYWORDS)).toEqual({
+ escalate: false,
+ });
+ expect(shouldForceEscalate(" ", DEFAULT_KEYWORDS)).toEqual({
+ escalate: false,
+ });
+ });
+
+ it("treats a non-array keyword argument as empty", () => {
+ expect(
+ shouldForceEscalate(
+ "what are your hours?",
+ undefined as unknown as string[],
+ ),
+ ).toEqual({ escalate: false });
+ });
+
+ it("treats non-string inbound text as no-escalate", () => {
+ expect(
+ shouldForceEscalate(null as unknown as string, DEFAULT_KEYWORDS),
+ ).toEqual({ escalate: false });
+ });
+});
+
+describe("shouldForceEscalate — explicit human-request detection", () => {
+ it("escalates on 'talk to a human' even with no configured keywords", () => {
+ expect(shouldForceEscalate("I want to talk to a human", [])).toEqual({
+ escalate: true,
+ reason: "keyword",
+ });
+ });
+
+ it("detects a range of hand-off phrasings", () => {
+ const phrases = [
+ "Can I speak to a person?",
+ "please connect me to an agent",
+ "I need a real person",
+ "let me talk to a representative",
+ "transfer me to a manager",
+ "I'd like a human agent",
+ "get me customer service",
+ "I want to speak with someone real",
+ ];
+ for (const text of phrases) {
+ expect(shouldForceEscalate(text, []).escalate).toBe(true);
+ }
+ });
+
+ it("is case-insensitive for human requests", () => {
+ expect(
+ shouldForceEscalate("TALK TO A HUMAN PLEASE", []).escalate,
+ ).toBe(true);
+ });
+
+ it("does not escalate on a passing mention of a person without a request", () => {
+ // No request verb paired with the noun → not an explicit hand-off.
+ expect(
+ shouldForceEscalate("your agent called me yesterday", []).escalate,
+ ).toBe(false);
+ expect(
+ shouldForceEscalate("the manager was very kind", []).escalate,
+ ).toBe(false);
+ });
+
+ it("tolerates extra whitespace inside a request phrase", () => {
+ expect(
+ shouldForceEscalate("talk to a human", []).escalate,
+ ).toBe(true);
+ });
+});
diff --git a/src/lib/ai/guardrails.ts b/src/lib/ai/guardrails.ts
new file mode 100644
index 0000000000..3c02ed36d0
--- /dev/null
+++ b/src/lib/ai/guardrails.ts
@@ -0,0 +1,184 @@
+/**
+ * Deterministic escalation guardrails (spec §6/§7).
+ *
+ * The first, pre-LLM half of the assistant's "fail safe to a human"
+ * bias: before any Anthropic call we scan the inbound text for
+ * account-configured escalation keywords and for explicit
+ * "talk to a human" style requests. A hit forces an escalation with no
+ * model call at all — cheaper, faster, and immune to a confused model.
+ *
+ * Pure and fully deterministic: no I/O, no clock, no randomness. All
+ * the logic worth testing lives here so it can be exercised without a
+ * Supabase / Anthropic mock (mirrors `src/lib/flows/fallback.ts`).
+ *
+ * Matching rules:
+ * - Case-insensitive.
+ * - Word-boundary anchored, so a keyword like `legal` matches
+ * "I need legal help" but NOT "illegal" or "delegal" — substring
+ * false-positives would silently route innocent traffic to a human.
+ * - The built-in "talk to a human/agent/person/..." detection is always
+ * on, independent of the configured keyword list, so an account that
+ * trimmed its keywords still honours an explicit hand-off request.
+ *
+ * The returned `reason` is the literal `AiEscalationReason` value
+ * `'keyword'` (spec §6: "keyword / 'talk to a human' → escalate
+ * (reason=keyword)"), so callers can write it straight to
+ * `ai_reply_log.reason` / `conversations.ai_escalation_reason`.
+ */
+
+import { type AiEscalationReason } from "@/types";
+
+/** Outcome of the deterministic guardrail scan. */
+export interface GuardrailResult {
+ /** True when the inbound text must be escalated without an LLM call. */
+ escalate: boolean;
+ /**
+ * Why we escalated — always the `'keyword'` reason for guardrail
+ * hits (covers both configured keywords and explicit human requests).
+ * Omitted when `escalate` is false.
+ */
+ reason?: AiEscalationReason;
+}
+
+/**
+ * Phrases that explicitly ask to be handed to a person. Always checked,
+ * regardless of the account's configured keyword list. Each entry is a
+ * lowercase token sequence; we match it word-boundary anchored so
+ * "speak to an agent" hits but "user agent" / "real estate agent"
+ * patterns still rely on the surrounding verb to qualify.
+ *
+ * We intentionally pair a request verb ("talk to", "speak to", etc.)
+ * with a human noun ("human", "agent", "person", ...) rather than
+ * matching the noun alone — the bare nouns are already (optionally) in
+ * the configured keyword list, and matching them unconditionally here
+ * would double-cover and over-escalate (e.g. "your agent called me").
+ */
+const HUMAN_REQUEST_VERBS = [
+ "talk to",
+ "speak to",
+ "speak with",
+ "talk with",
+ "chat with",
+ "connect me to",
+ "connect me with",
+ "transfer me to",
+ "put me through to",
+ "get me",
+ "i want",
+ "i need",
+ "i'd like",
+ "i would like",
+ "can i talk to",
+ "can i speak to",
+ "let me talk to",
+ "let me speak to",
+] as const;
+
+const HUMAN_REQUEST_NOUNS = [
+ "a human",
+ "a real human",
+ "a real person",
+ "a person",
+ "a real agent",
+ "an agent",
+ "a live agent",
+ "a human agent",
+ "a representative",
+ "a rep",
+ "a manager",
+ "a real human being",
+ "customer service",
+ "customer support",
+ "support agent",
+ "someone real",
+ "a real one",
+] as const;
+
+/**
+ * Escape a string for safe use inside a `RegExp`, and collapse runs of
+ * whitespace in the source phrase into `\s+` so multi-word phrases
+ * tolerate any amount/kind of inter-word whitespace in the input.
+ */
+function phraseToPattern(phrase: string): string {
+ return phrase
+ .trim()
+ .split(/\s+/)
+ .map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
+ .join("\\s+");
+}
+
+/**
+ * Build a single case-insensitive, word-boundary-anchored regex for a
+ * phrase. `\b` is used where the phrase edge is a word character; for
+ * phrases whose edges are non-word characters (e.g. "i'd like") we lean
+ * on the surrounding structure instead — but every entry here begins
+ * and ends with a word character, so `\b` on both sides is sound.
+ */
+function phraseRegex(phrase: string): RegExp {
+ return new RegExp(`\\b${phraseToPattern(phrase)}\\b`, "i");
+}
+
+/**
+ * Pre-built explicit-human-request matchers: the cross product of
+ * request verbs and human nouns ("talk to a human", "i need a manager",
+ * …). Built once at module load — the lists are static.
+ */
+const HUMAN_REQUEST_REGEXES: RegExp[] = (() => {
+ const out: RegExp[] = [];
+ for (const verb of HUMAN_REQUEST_VERBS) {
+ for (const noun of HUMAN_REQUEST_NOUNS) {
+ out.push(phraseRegex(`${verb} ${noun}`));
+ }
+ }
+ return out;
+})();
+
+/** True when the text explicitly asks to be handed to a person. */
+function requestsHuman(text: string): boolean {
+ return HUMAN_REQUEST_REGEXES.some((re) => re.test(text));
+}
+
+/**
+ * True when any configured escalation keyword appears in the text as a
+ * whole word (case-insensitive). Blank / whitespace-only keywords are
+ * skipped so a stray empty string in the array can't match everything.
+ */
+function matchesKeyword(text: string, keywords: readonly string[]): boolean {
+ for (const keyword of keywords) {
+ if (typeof keyword !== "string") continue;
+ const trimmed = keyword.trim();
+ if (trimmed.length === 0) continue;
+ if (phraseRegex(trimmed).test(text)) return true;
+ }
+ return false;
+}
+
+/**
+ * Decide whether an inbound customer message must be escalated to a
+ * human BEFORE any LLM call.
+ *
+ * Returns `{ escalate: true, reason: 'keyword' }` when either:
+ * - the text contains a configured escalation keyword as a whole word
+ * (case-insensitive, word-boundary anchored), OR
+ * - the text is an explicit "talk to a human / agent / person" request.
+ *
+ * Otherwise returns `{ escalate: false }` and the caller proceeds to the
+ * model. Empty / non-string input and an empty keyword list are handled
+ * safely (no escalation, unless an explicit human request is present).
+ */
+export function shouldForceEscalate(
+ inboundText: string,
+ escalationKeywords: readonly string[],
+): GuardrailResult {
+ if (typeof inboundText !== "string" || inboundText.trim().length === 0) {
+ return { escalate: false };
+ }
+
+ const keywords = Array.isArray(escalationKeywords) ? escalationKeywords : [];
+
+ if (matchesKeyword(inboundText, keywords) || requestsHuman(inboundText)) {
+ return { escalate: true, reason: "keyword" };
+ }
+
+ return { escalate: false };
+}
diff --git a/src/lib/ai/knowledge-base.ts b/src/lib/ai/knowledge-base.ts
new file mode 100644
index 0000000000..873c8ac3fc
--- /dev/null
+++ b/src/lib/ai/knowledge-base.ts
@@ -0,0 +1,78 @@
+/**
+ * Knowledge-base data access for the AI assistant (spec §4.2, §5).
+ *
+ * Two pieces:
+ *
+ * 1. `loadEnabledEntries` — a thin service-role read of the *enabled*
+ * `knowledge_base_entries` for one account. Strict per-account
+ * isolation is enforced here at the query level (`.eq("account_id",
+ * accountId)`) and again, structurally, in `prompt.ts` — there is no
+ * global/shared KB in v1 (spec §7.4, §12).
+ *
+ * 2. `estimateTokens` — the chars/4 heuristic used for the Settings size
+ * meter and the `token_estimate` cache column (spec §4.2, §9). Pure.
+ *
+ * The webhook path runs as service-role and bypasses RLS exactly as the
+ * Flows and automations engines already do (spec §4 / §12).
+ */
+
+import { type KnowledgeBaseEntry } from "@/types";
+
+import { supabaseAdmin } from "./admin-client";
+
+/**
+ * Divisor for the rough token estimate. ~4 characters per token is the
+ * standard English heuristic; it intentionally over- rather than
+ * under-counts so the size meter trips conservatively (spec §4.2 / §9).
+ */
+const CHARS_PER_TOKEN = 4;
+
+/**
+ * Load the *enabled* knowledge-base entries for an account, ordered
+ * oldest→newest (`created_at` ascending) so the assembled prompt is
+ * stable and reproducible across calls.
+ *
+ * Disabled entries are excluded at the query level (spec §4.2: "Disabled
+ * entries are excluded from the prompt") — `prompt.ts` also filters
+ * defensively, but not sending them over the wire keeps the payload lean.
+ *
+ * Returns an empty array on any DB error so the caller can still proceed:
+ * an empty KB drives the model to `confident: false` rather than guessing
+ * (spec §7.2), which is the safe fail-to-human outcome (spec §1).
+ */
+export async function loadEnabledEntries(
+ accountId: string,
+): Promise {
+ const db = supabaseAdmin();
+
+ const { data, error } = await db
+ .from("knowledge_base_entries")
+ .select("*")
+ .eq("account_id", accountId)
+ .eq("enabled", true)
+ .order("created_at", { ascending: true });
+
+ if (error) {
+ console.error("[ai] loadEnabledEntries error:", error.message);
+ return [];
+ }
+
+ return (data as KnowledgeBaseEntry[] | null) ?? [];
+}
+
+/**
+ * Estimate the token count of a piece of text with the chars/4 heuristic
+ * (spec §4.2). Pure and deterministic — no tokenizer, no I/O. Used for the
+ * cached `token_estimate` column and the Settings size meter.
+ *
+ * Returns `0` for empty / non-string input so callers can sum estimates
+ * across entries without guarding each one. Uses `Math.ceil` so any
+ * non-empty text counts as at least one token.
+ */
+export function estimateTokens(text: string): number {
+ if (typeof text !== "string" || text.length === 0) {
+ return 0;
+ }
+
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
+}
diff --git a/src/lib/ai/prompt.test.ts b/src/lib/ai/prompt.test.ts
new file mode 100644
index 0000000000..a51679dbd3
--- /dev/null
+++ b/src/lib/ai/prompt.test.ts
@@ -0,0 +1,315 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ HISTORY_MESSAGE_LIMIT,
+ buildMessages,
+ buildSystemBlocks,
+ type PromptHistoryMessage,
+} from "./prompt";
+import { type AiAssistantConfig, type KnowledgeBaseEntry } from "@/types";
+
+// ------------------------------------------------------------------
+// Fixtures
+// ------------------------------------------------------------------
+
+function makeConfig(overrides: Partial = {}): AiAssistantConfig {
+ return {
+ id: "cfg-1",
+ account_id: "acct-A",
+ enabled: true,
+ system_prompt:
+ "You are the support assistant for {business_name}. Answer only from the knowledge base.",
+ escalation_keywords: [],
+ business_name: "Acme Co",
+ model: "claude-sonnet-4-6",
+ daily_reply_cap: 500,
+ created_at: "2026-06-25T00:00:00Z",
+ updated_at: "2026-06-25T00:00:00Z",
+ ...overrides,
+ };
+}
+
+let kbSeq = 0;
+function makeEntry(overrides: Partial = {}): KnowledgeBaseEntry {
+ kbSeq += 1;
+ return {
+ id: `kb-${kbSeq}`,
+ account_id: "acct-A",
+ title: `Title ${kbSeq}`,
+ content: `Content ${kbSeq}`,
+ source_type: "manual",
+ enabled: true,
+ created_at: "2026-06-25T00:00:00Z",
+ updated_at: "2026-06-25T00:00:00Z",
+ ...overrides,
+ };
+}
+
+/** The single cache-marked block is always the KB block (index 1). */
+function kbBlock(config: AiAssistantConfig, entries: KnowledgeBaseEntry[]) {
+ return buildSystemBlocks(config, entries)[1];
+}
+
+// ------------------------------------------------------------------
+// buildSystemBlocks — persona block
+// ------------------------------------------------------------------
+
+describe("buildSystemBlocks — persona block", () => {
+ it("emits exactly two text blocks: persona then KB", () => {
+ const blocks = buildSystemBlocks(makeConfig(), [makeEntry()]);
+ expect(blocks).toHaveLength(2);
+ expect(blocks[0].type).toBe("text");
+ expect(blocks[1].type).toBe("text");
+ });
+
+ it("substitutes {business_name} into the persona prompt", () => {
+ const blocks = buildSystemBlocks(
+ makeConfig({ business_name: "Globex" }),
+ [],
+ );
+ expect(blocks[0].text).toContain("Globex");
+ expect(blocks[0].text).not.toContain("{business_name}");
+ });
+
+ it("replaces every {business_name} occurrence, not just the first", () => {
+ const blocks = buildSystemBlocks(
+ makeConfig({
+ system_prompt: "{business_name} cares. Welcome to {business_name}.",
+ business_name: "Initech",
+ }),
+ [],
+ );
+ expect(blocks[0].text).toBe("Initech cares. Welcome to Initech.");
+ });
+
+ it("falls back to a neutral name when business_name is missing/blank", () => {
+ for (const business_name of [undefined, "", " "]) {
+ const blocks = buildSystemBlocks(makeConfig({ business_name }), []);
+ expect(blocks[0].text).not.toContain("{business_name}");
+ expect(blocks[0].text).toContain("our business");
+ }
+ });
+
+ it("does NOT cache-mark the persona block (KB is the breakpoint)", () => {
+ const blocks = buildSystemBlocks(makeConfig(), [makeEntry()]);
+ expect(blocks[0].cache_control).toBeUndefined();
+ });
+});
+
+// ------------------------------------------------------------------
+// buildSystemBlocks — KB block assembly
+// ------------------------------------------------------------------
+
+describe("buildSystemBlocks — KB assembly", () => {
+ it("renders each entry as '## {title}\\n{content}'", () => {
+ const entry = makeEntry({ title: "Refund policy", content: "30 days." });
+ const text = kbBlock(makeConfig(), [entry]).text;
+ expect(text).toContain("## Refund policy\n30 days.");
+ });
+
+ it("wraps the KB in clear start/end delimiters", () => {
+ const text = kbBlock(makeConfig(), [makeEntry()]).text;
+ expect(text).toContain("KNOWLEDGE BASE (START)");
+ expect(text).toContain("KNOWLEDGE BASE (END)");
+ // Delimiters bracket the body.
+ expect(text.indexOf("(START)")).toBeLessThan(text.indexOf("(END)"));
+ });
+
+ it("emits a non-empty, still-delimited block when there are no entries", () => {
+ const text = kbBlock(makeConfig(), []).text;
+ expect(text).toContain("KNOWLEDGE BASE (START)");
+ expect(text).toContain("KNOWLEDGE BASE (END)");
+ expect(text.trim().length).toBeGreaterThan(0);
+ });
+});
+
+// ------------------------------------------------------------------
+// buildSystemBlocks — cache_control placement (spec §7.2)
+// ------------------------------------------------------------------
+
+describe("buildSystemBlocks — cache_control placement", () => {
+ it("marks the KB block with cache_control { type: 'ephemeral' }", () => {
+ const block = kbBlock(makeConfig(), [makeEntry()]);
+ expect(block.cache_control).toEqual({ type: "ephemeral" });
+ });
+
+ it("marks the KB block even when empty (stable cacheable prefix)", () => {
+ expect(kbBlock(makeConfig(), []).cache_control).toEqual({
+ type: "ephemeral",
+ });
+ });
+
+ it("places cache_control on exactly one block — the last (KB) one", () => {
+ const blocks = buildSystemBlocks(makeConfig(), [makeEntry(), makeEntry()]);
+ const marked = blocks.filter((b) => b.cache_control !== undefined);
+ expect(marked).toHaveLength(1);
+ expect(marked[0]).toBe(blocks[blocks.length - 1]);
+ });
+});
+
+// ------------------------------------------------------------------
+// Account isolation invariant (spec §7.4): only-and-all provided
+// entries appear, in order. The function is blind to account_id.
+// ------------------------------------------------------------------
+
+describe("buildSystemBlocks — account isolation (only-and-all, in order)", () => {
+ it("includes every provided enabled entry, in the order given", () => {
+ const entries = [
+ makeEntry({ title: "A", content: "alpha" }),
+ makeEntry({ title: "B", content: "bravo" }),
+ makeEntry({ title: "C", content: "charlie" }),
+ ];
+ const text = kbBlock(makeConfig(), entries).text;
+ expect(text.indexOf("## A")).toBeGreaterThanOrEqual(0);
+ expect(text.indexOf("## A")).toBeLessThan(text.indexOf("## B"));
+ expect(text.indexOf("## B")).toBeLessThan(text.indexOf("## C"));
+ });
+
+ it("includes ONLY the provided entries — nothing else leaks in", () => {
+ const entries = [
+ makeEntry({ title: "Hours", content: "9 to 5" }),
+ makeEntry({ title: "Shipping", content: "Free over $50" }),
+ ];
+ const text = kbBlock(makeConfig(), entries).text;
+ // Count the entry headers: exactly the two we supplied.
+ expect(text.match(/^## /gm)).toHaveLength(2);
+ expect(text).toContain("## Hours");
+ expect(text).toContain("## Shipping");
+ });
+
+ it("does NOT filter, reorder, or dedup by account_id — it is account-blind", () => {
+ // Two entries tagged with DIFFERENT account_ids. The caller is
+ // responsible for pre-filtering; if it hands us a foreign-account
+ // entry, we faithfully include it (and the caller's test catches the
+ // leak upstream). This asserts the function adds no hidden filter
+ // that could mask a caller bug, and preserves given order verbatim.
+ const entries = [
+ makeEntry({ account_id: "acct-A", title: "Mine", content: "x" }),
+ makeEntry({ account_id: "acct-OTHER", title: "Theirs", content: "y" }),
+ ];
+ const text = kbBlock(makeConfig({ account_id: "acct-A" }), entries).text;
+ expect(text.match(/^## /gm)).toHaveLength(2);
+ expect(text.indexOf("## Mine")).toBeLessThan(text.indexOf("## Theirs"));
+ });
+
+ it("excludes disabled entries but preserves the order of the rest", () => {
+ const entries = [
+ makeEntry({ title: "One", enabled: true }),
+ makeEntry({ title: "Two", enabled: false }),
+ makeEntry({ title: "Three", enabled: true }),
+ ];
+ const text = kbBlock(makeConfig(), entries).text;
+ expect(text).toContain("## One");
+ expect(text).not.toContain("## Two");
+ expect(text).toContain("## Three");
+ expect(text.match(/^## /gm)).toHaveLength(2);
+ expect(text.indexOf("## One")).toBeLessThan(text.indexOf("## Three"));
+ });
+});
+
+// ------------------------------------------------------------------
+// buildMessages — role mapping & ordering (spec §7.3)
+// ------------------------------------------------------------------
+
+describe("buildMessages — role mapping & ordering", () => {
+ it("maps customer→user and agent/bot→assistant, oldest→newest, then appends inbound", () => {
+ const history: PromptHistoryMessage[] = [
+ { sender_type: "customer", content_text: "hi" },
+ { sender_type: "bot", content_text: "hello, how can I help?" },
+ { sender_type: "agent", content_text: "this is a human agent" },
+ ];
+ expect(buildMessages(history, "what are your hours?")).toEqual([
+ { role: "user", content: "hi" },
+ { role: "assistant", content: "hello, how can I help?" },
+ { role: "assistant", content: "this is a human agent" },
+ { role: "user", content: "what are your hours?" },
+ ]);
+ });
+
+ it("always appends the inbound text as the final user turn", () => {
+ const result = buildMessages([], "first contact");
+ expect(result).toEqual([{ role: "user", content: "first contact" }]);
+ });
+
+ it("preserves history order verbatim (no sorting)", () => {
+ const history: PromptHistoryMessage[] = [
+ { sender_type: "customer", content_text: "msg-1" },
+ { sender_type: "customer", content_text: "msg-2" },
+ { sender_type: "customer", content_text: "msg-3" },
+ ];
+ const contents = buildMessages(history, "msg-4").map((m) => m.content);
+ expect(contents).toEqual(["msg-1", "msg-2", "msg-3", "msg-4"]);
+ });
+
+ it("drops history turns with empty/blank/missing text", () => {
+ const history: PromptHistoryMessage[] = [
+ { sender_type: "customer", content_text: "keep me" },
+ { sender_type: "bot", content_text: "" },
+ { sender_type: "bot", content_text: " " },
+ { sender_type: "customer", content_text: null },
+ { sender_type: "customer" },
+ ];
+ expect(buildMessages(history, "inbound")).toEqual([
+ { role: "user", content: "keep me" },
+ { role: "user", content: "inbound" },
+ ]);
+ });
+
+ it("treats a non-array history as empty", () => {
+ expect(
+ buildMessages(undefined as unknown as PromptHistoryMessage[], "hi"),
+ ).toEqual([{ role: "user", content: "hi" }]);
+ });
+});
+
+// ------------------------------------------------------------------
+// buildMessages — history cap (spec §7.3 / §13)
+// ------------------------------------------------------------------
+
+describe("buildMessages — history cap", () => {
+ it("keeps only the last HISTORY_MESSAGE_LIMIT history turns + the inbound", () => {
+ const history: PromptHistoryMessage[] = Array.from(
+ { length: 25 },
+ (_unused, i) => ({
+ sender_type: "customer" as const,
+ content_text: `h${i}`,
+ }),
+ );
+
+ const result = buildMessages(history, "INBOUND");
+
+ // N history turns + the appended inbound.
+ expect(result).toHaveLength(HISTORY_MESSAGE_LIMIT + 1);
+ // The kept turns are the most-recent N (h15..h24), in order.
+ expect(result[0].content).toBe(`h${25 - HISTORY_MESSAGE_LIMIT}`);
+ expect(result[HISTORY_MESSAGE_LIMIT - 1].content).toBe("h24");
+ // Inbound is last.
+ expect(result[result.length - 1]).toEqual({
+ role: "user",
+ content: "INBOUND",
+ });
+ });
+
+ it("applies the cap AFTER dropping blank turns (caps real turns only)", () => {
+ // 12 real turns interleaved with blanks. After dropping blanks we
+ // have 12 usable; the cap keeps the last 10 of those.
+ const history: PromptHistoryMessage[] = [];
+ for (let i = 0; i < 12; i += 1) {
+ history.push({ sender_type: "customer", content_text: `real-${i}` });
+ history.push({ sender_type: "bot", content_text: " " }); // blank
+ }
+
+ const result = buildMessages(history, "INBOUND");
+
+ expect(result).toHaveLength(HISTORY_MESSAGE_LIMIT + 1);
+ expect(result[0].content).toBe("real-2"); // 12 real, last 10 → real-2..real-11
+ expect(result[HISTORY_MESSAGE_LIMIT - 1].content).toBe("real-11");
+ });
+
+ it("does not pad when history is shorter than the cap", () => {
+ const history: PromptHistoryMessage[] = [
+ { sender_type: "customer", content_text: "only one" },
+ ];
+ expect(buildMessages(history, "two")).toHaveLength(2);
+ });
+});
diff --git a/src/lib/ai/prompt.ts b/src/lib/ai/prompt.ts
new file mode 100644
index 0000000000..c9b8bc5588
--- /dev/null
+++ b/src/lib/ai/prompt.ts
@@ -0,0 +1,187 @@
+/**
+ * Prompt assembly for the WhatsApp AI assistant (spec §7).
+ *
+ * Pure and fully deterministic: no I/O, no clock, no randomness, no
+ * network. Given an account's config + its KB entries + the recent
+ * conversation history + the inbound text, it builds the two pieces the
+ * Anthropic wrapper hands to `messages.create`:
+ *
+ * 1. `buildSystemBlocks` → the `system` array. A stable
+ * persona/instructions text block first, then the assembled
+ * knowledge-base block carrying `cache_control: { type: 'ephemeral' }`
+ * so it bills at ~10% on cache hits (spec §7.2, §13). Ordering is
+ * deliberate: stable instructions first, the most-cacheable
+ * last-stable content (the KB) last.
+ * 2. `buildMessages` → the `messages` array: the last N (default 10)
+ * history turns mapped oldest→newest to `user`/`assistant` roles,
+ * then the inbound customer text appended as the final `user` turn.
+ * History is NOT cached — it changes every turn (spec §7.3).
+ *
+ * Account isolation (spec §7.4, critical): KB entries arrive
+ * ALREADY filtered by `account_id`. This module is deliberately blind to
+ * tenancy — it includes exactly the entries it is handed, in the order
+ * it is handed them, and never reaches for a global/shared KB. The unit
+ * test asserts that "only and all provided entries appear, in order",
+ * which is the testable form of the isolation guarantee.
+ *
+ * The shapes returned are the canonical Anthropic SDK param types
+ * (`Anthropic.TextBlockParam`, `Anthropic.MessageParam`) so the wrapper
+ * can pass them straight through with no remapping.
+ */
+
+import type Anthropic from "@anthropic-ai/sdk";
+
+import { type AiAssistantConfig, type KnowledgeBaseEntry } from "@/types";
+
+/**
+ * Default number of conversation history turns to include for context
+ * (spec §7.3 / §13). Oldest→newest; the cap keeps token cost bounded.
+ */
+export const HISTORY_MESSAGE_LIMIT = 10;
+
+/**
+ * Delimiters around the assembled knowledge base. Clear, unambiguous
+ * fences (spec §7.2: "wrapped in clear delimiters") so the model can
+ * tell exactly where the grounding material begins and ends and can't
+ * confuse KB content with instructions.
+ */
+const KB_HEADER = "===== KNOWLEDGE BASE (START) =====";
+const KB_FOOTER = "===== KNOWLEDGE BASE (END) =====";
+
+/**
+ * Shown in place of the KB body when an account has no enabled entries.
+ * The block is still emitted (and still cache-marked) so the prompt
+ * structure is stable; the empty marker tells the model it has nothing
+ * to ground on — which, combined with the "answer ONLY from the KB"
+ * system prompt, drives it to `confident: false` rather than guessing.
+ */
+const KB_EMPTY_PLACEHOLDER = "(No knowledge base entries are available.)";
+
+/**
+ * Minimal history-message shape consumed by `buildMessages`. Decoupled
+ * from the DB `Message` row on purpose — the caller projects whatever it
+ * loaded down to just the sender + text, so this stays a pure function
+ * with no dependency on the persistence layer.
+ */
+export interface PromptHistoryMessage {
+ /** Who sent it: `customer` → `user`; `agent` / `bot` → `assistant`. */
+ sender_type: "customer" | "agent" | "bot";
+ /** The text body. Empty / missing entries are dropped (see below). */
+ content_text?: string | null;
+}
+
+/**
+ * Substitute the `{business_name}` placeholder in the system prompt with
+ * the configured business name (spec §7.2). Falls back to a neutral
+ * phrase when no name is set so the prompt never renders a literal
+ * `{business_name}` to the model. `replace` with a string pattern only
+ * swaps the first occurrence, so we use a global regex.
+ */
+function renderPersona(config: AiAssistantConfig): string {
+ const businessName =
+ typeof config.business_name === "string" &&
+ config.business_name.trim().length > 0
+ ? config.business_name.trim()
+ : "our business";
+
+ return config.system_prompt.replace(/\{business_name\}/g, businessName);
+}
+
+/**
+ * Assemble the knowledge-base text from the enabled entries, in the
+ * exact order received. Each entry renders as `## {title}\n{content}`
+ * (spec §7.2); entries are separated by a blank line for readability.
+ *
+ * Disabled entries are skipped (spec §4.2: "Disabled entries are
+ * excluded from the prompt"). Order is otherwise preserved verbatim —
+ * no sorting, no dedup — so the assembled block is a faithful, ordered
+ * projection of exactly what the caller passed.
+ */
+function assembleKnowledgeBase(entries: readonly KnowledgeBaseEntry[]): string {
+ const rendered = entries
+ .filter((entry) => entry.enabled)
+ .map((entry) => `## ${entry.title}\n${entry.content}`);
+
+ const body = rendered.length > 0 ? rendered.join("\n\n") : KB_EMPTY_PLACEHOLDER;
+
+ return `${KB_HEADER}\n${body}\n${KB_FOOTER}`;
+}
+
+/**
+ * Build the Anthropic `system` array (spec §7.2).
+ *
+ * Returns two text blocks, in order:
+ * 1. The stable persona/instructions block (`config.system_prompt` with
+ * `{business_name}` substituted). NOT cache-marked here — the KB is
+ * the cache breakpoint, and a breakpoint caches everything up to and
+ * including it, so a single marker on the trailing KB block covers
+ * the whole stable prefix.
+ * 2. The assembled knowledge base, carrying
+ * `cache_control: { type: 'ephemeral' }`.
+ *
+ * KB entries MUST already be filtered to the calling account (spec
+ * §7.4). This function includes exactly the entries it is given.
+ */
+export function buildSystemBlocks(
+ config: AiAssistantConfig,
+ kbEntries: readonly KnowledgeBaseEntry[],
+): Anthropic.TextBlockParam[] {
+ const persona: Anthropic.TextBlockParam = {
+ type: "text",
+ text: renderPersona(config),
+ };
+
+ const knowledgeBase: Anthropic.TextBlockParam = {
+ type: "text",
+ text: assembleKnowledgeBase(kbEntries),
+ cache_control: { type: "ephemeral" },
+ };
+
+ return [persona, knowledgeBase];
+}
+
+/**
+ * Map a stored sender type to an Anthropic message role (spec §7.3):
+ * the customer is the `user`; everything we send back (a human `agent`
+ * or the `bot` itself) is the `assistant`.
+ */
+function roleFor(senderType: PromptHistoryMessage["sender_type"]): "user" | "assistant" {
+ return senderType === "customer" ? "user" : "assistant";
+}
+
+/**
+ * Build the Anthropic `messages` array (spec §7.3).
+ *
+ * Takes the conversation `history` (any length, oldest→newest), keeps
+ * the last `HISTORY_MESSAGE_LIMIT` turns, maps each to a `user` /
+ * `assistant` role, then appends `inboundText` as the final `user`
+ * message. History turns with empty / whitespace-only text are dropped
+ * (an empty `content` is not a valid message turn) BEFORE the cap is
+ * applied, so the cap counts only real turns.
+ *
+ * The inbound message is always appended verbatim as the last `user`
+ * turn — it is the message we are answering and must never be trimmed.
+ */
+export function buildMessages(
+ history: readonly PromptHistoryMessage[],
+ inboundText: string,
+): Anthropic.MessageParam[] {
+ const safeHistory = Array.isArray(history) ? history : [];
+
+ const usable = safeHistory.filter(
+ (msg): msg is PromptHistoryMessage =>
+ typeof msg?.content_text === "string" &&
+ msg.content_text.trim().length > 0,
+ );
+
+ const recent = usable.slice(-HISTORY_MESSAGE_LIMIT);
+
+ const messages: Anthropic.MessageParam[] = recent.map((msg) => ({
+ role: roleFor(msg.sender_type),
+ content: msg.content_text as string,
+ }));
+
+ messages.push({ role: "user", content: inboundText });
+
+ return messages;
+}
diff --git a/src/lib/ai/reply.test.ts b/src/lib/ai/reply.test.ts
new file mode 100644
index 0000000000..d666cbbc4b
--- /dev/null
+++ b/src/lib/ai/reply.test.ts
@@ -0,0 +1,470 @@
+/**
+ * Orchestration tests for `maybeReplyToInbound` (spec §6, §14).
+ *
+ * The Anthropic SDK and the service-role Supabase admin client are mocked;
+ * `callAssistant` is injected per call (spec §14). No network, no DB. We
+ * drive the engine entirely through programmable mock state and assert the
+ * terminal effects:
+ * disabled → skip (no model call, no send, no escalate)
+ * human-owned → skip
+ * keyword → escalate, model NOT called
+ * cap reached → escalate, model NOT called
+ * low confidence → escalate + log escalated
+ * happy path → send + log replied (with usage + latency)
+ * thrown error → escalate + log error (never throws)
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { type AiAssistantConfig } from '@/types'
+
+// ---- Programmable service-role admin-client mock --------------------------
+// Lives in a hoisted block so the vi.mock factory below can close over it.
+// Mirrors the builder-style mock in src/lib/automations/engine.test.ts.
+const h = vi.hoisted(() => ({
+ state: {
+ config: null as AiAssistantConfig | null,
+ conversation: { ai_handling: true } as { ai_handling?: boolean } | null,
+ contactPhone: '+254700111222' as string | null,
+ kbEntries: [] as Record[],
+ history: [] as Record[],
+ repliesTodayCount: 0 as number,
+ // Force an error on a specific table read to exercise the catch path.
+ failTable: null as string | null,
+ // Captured effects.
+ insertedLogs: [] as Record[],
+ fromCalls: [] as string[],
+ },
+}))
+
+vi.mock('./admin-client', () => {
+ const { state } = h
+
+ function resolve(ops: {
+ table: string
+ type: string
+ payload?: unknown
+ head: boolean
+ }): { data: unknown; error: unknown; count?: number } {
+ const { table, type } = ops
+
+ if (state.failTable === table) {
+ return { data: null, error: { message: `forced failure on ${table}` } }
+ }
+
+ if (table === 'ai_assistant_config') {
+ return { data: state.config, error: null }
+ }
+ if (table === 'conversations') {
+ if (type === 'select') return { data: state.conversation, error: null }
+ return { data: null, error: null } // update (escalate is mocked, but be safe)
+ }
+ if (table === 'contacts') {
+ return { data: state.contactPhone ? { phone: state.contactPhone } : null, error: null }
+ }
+ if (table === 'knowledge_base_entries') {
+ return { data: state.kbEntries, error: null }
+ }
+ if (table === 'messages') {
+ return { data: state.history, error: null }
+ }
+ if (table === 'ai_reply_log') {
+ if (type === 'insert') {
+ state.insertedLogs.push((ops.payload as Record) ?? {})
+ return { data: null, error: null }
+ }
+ // count query (head: true)
+ return { data: null, error: null, count: state.repliesTodayCount }
+ }
+ return { data: null, error: null }
+ }
+
+ function builder(table: string) {
+ const ops = { table, type: 'select', payload: undefined as unknown, head: false }
+ const b: Record = {
+ select: (_cols?: unknown, opts?: { head?: boolean }) => {
+ if (opts?.head) ops.head = true
+ return b
+ },
+ insert: (p: unknown) => ((ops.type = 'insert'), (ops.payload = p), b),
+ update: (p: unknown) => ((ops.type = 'update'), (ops.payload = p), b),
+ eq: () => b,
+ gte: () => b,
+ order: () => b,
+ limit: () => Promise.resolve(resolve(ops)),
+ maybeSingle: () => Promise.resolve(resolve(ops)),
+ single: () => Promise.resolve(resolve(ops)),
+ then: (onF: (v: unknown) => unknown, onR?: (e: unknown) => unknown) =>
+ Promise.resolve(resolve(ops)).then(onF, onR),
+ }
+ return b
+ }
+
+ return {
+ supabaseAdmin: () => ({
+ from: (t: string) => {
+ h.state.fromCalls.push(t)
+ return builder(t)
+ },
+ }),
+ }
+})
+
+// Mock the network-touching siblings so we assert calls without Meta I/O.
+const sendAiReply = vi.fn(
+ async (_args: unknown) => ({ whatsapp_message_id: 'wamid.1' }),
+)
+const escalateConversation = vi.fn(async (_args: unknown) => {})
+vi.mock('./send', () => ({ sendAiReply: (a: unknown) => sendAiReply(a) }))
+vi.mock('./escalate', () => ({
+ escalateConversation: (a: unknown) => escalateConversation(a),
+}))
+
+// Guard: the real Anthropic SDK must never be constructed in tests.
+vi.mock('@anthropic-ai/sdk', () => ({
+ default: class {
+ constructor() {
+ throw new Error('Anthropic SDK was constructed in a test')
+ }
+ },
+}))
+
+import { maybeReplyToInbound } from './reply'
+
+// ---- Fixtures -------------------------------------------------------------
+
+function config(overrides: Partial = {}): AiAssistantConfig {
+ return {
+ id: 'cfg-1',
+ account_id: 'acct-1',
+ enabled: true,
+ system_prompt: 'You are support for {business_name}.',
+ handoff_message: 'A human will be with you shortly.',
+ escalation_keywords: ['refund', 'lawyer'],
+ business_name: 'Acme',
+ model: 'claude-sonnet-4-6',
+ daily_reply_cap: 500,
+ created_at: '2026-06-25T00:00:00Z',
+ updated_at: '2026-06-25T00:00:00Z',
+ ...overrides,
+ }
+}
+
+const BASE_ARGS = {
+ accountId: 'acct-1',
+ conversationId: 'conv-1',
+ contactId: 'contact-1',
+ messageId: 'msg-1',
+ accessToken: 'token-xyz',
+ phoneNumberId: 'pn-1',
+}
+
+/** A confident, non-empty model verdict — drives the happy path. */
+function confidentResult() {
+ return {
+ answer: 'We are open 9am to 5pm, Monday to Friday.',
+ confident: true,
+ reason: 'Opening hours are in the KB.',
+ usage: { input_tokens: 1200, output_tokens: 40, cache_read_input_tokens: 1000 },
+ }
+}
+
+/** A not-confident verdict — drives the low-confidence escalation. */
+function unsureResult() {
+ return {
+ answer: '',
+ confident: false,
+ reason: 'KB does not cover this.',
+ usage: { input_tokens: 1200, output_tokens: 10, cache_read_input_tokens: 1000 },
+ }
+}
+
+beforeEach(() => {
+ h.state.config = config()
+ h.state.conversation = { ai_handling: true }
+ h.state.contactPhone = '+254700111222'
+ h.state.kbEntries = [
+ {
+ id: 'kb-1',
+ account_id: 'acct-1',
+ title: 'Hours',
+ content: 'Open 9-5 Mon-Fri.',
+ source_type: 'manual',
+ enabled: true,
+ created_at: '2026-06-01T00:00:00Z',
+ updated_at: '2026-06-01T00:00:00Z',
+ },
+ ]
+ h.state.history = []
+ h.state.repliesTodayCount = 0
+ h.state.failTable = null
+ h.state.insertedLogs = []
+ h.state.fromCalls = []
+ sendAiReply.mockClear()
+ escalateConversation.mockClear()
+})
+
+function lastLog() {
+ return h.state.insertedLogs[h.state.insertedLogs.length - 1]
+}
+
+// ---- Tests ----------------------------------------------------------------
+
+describe('maybeReplyToInbound — disabled → skip', () => {
+ it('skips when no config row exists (AI never configured)', async () => {
+ h.state.config = null
+ const callAssistant = vi.fn()
+
+ await maybeReplyToInbound({ ...BASE_ARGS, inboundText: 'hi', callAssistant })
+
+ expect(callAssistant).not.toHaveBeenCalled()
+ expect(sendAiReply).not.toHaveBeenCalled()
+ expect(escalateConversation).not.toHaveBeenCalled()
+ expect(lastLog()).toMatchObject({ decision: 'skipped', reason: 'no_config' })
+ })
+
+ it('skips when config exists but enabled is false', async () => {
+ h.state.config = config({ enabled: false })
+ const callAssistant = vi.fn()
+
+ await maybeReplyToInbound({ ...BASE_ARGS, inboundText: 'hi', callAssistant })
+
+ expect(callAssistant).not.toHaveBeenCalled()
+ expect(sendAiReply).not.toHaveBeenCalled()
+ expect(escalateConversation).not.toHaveBeenCalled()
+ expect(lastLog()).toMatchObject({ decision: 'skipped', reason: 'disabled' })
+ })
+})
+
+describe('maybeReplyToInbound — human-owned → skip', () => {
+ it('skips when the conversation has ai_handling=false (a human took over)', async () => {
+ h.state.conversation = { ai_handling: false }
+ const callAssistant = vi.fn()
+
+ await maybeReplyToInbound({ ...BASE_ARGS, inboundText: 'hello', callAssistant })
+
+ expect(callAssistant).not.toHaveBeenCalled()
+ expect(sendAiReply).not.toHaveBeenCalled()
+ expect(escalateConversation).not.toHaveBeenCalled()
+ expect(lastLog()).toMatchObject({ decision: 'skipped', reason: 'human_takeover' })
+ })
+})
+
+describe('maybeReplyToInbound — keyword → escalate (no model call)', () => {
+ it('escalates on a configured keyword WITHOUT calling the model', async () => {
+ const callAssistant = vi.fn()
+
+ await maybeReplyToInbound({
+ ...BASE_ARGS,
+ inboundText: 'I want a refund please',
+ callAssistant,
+ })
+
+ // The model must NOT be called on a guardrail hit (spec §6).
+ expect(callAssistant).not.toHaveBeenCalled()
+ expect(sendAiReply).not.toHaveBeenCalled()
+ expect(escalateConversation).toHaveBeenCalledTimes(1)
+ expect(escalateConversation).toHaveBeenCalledWith(
+ expect.objectContaining({ conversationId: 'conv-1', reason: 'keyword' }),
+ )
+ expect(lastLog()).toMatchObject({ decision: 'escalated', reason: 'keyword' })
+ })
+
+ it('escalates on an explicit "talk to a human" request without the model', async () => {
+ const callAssistant = vi.fn()
+
+ await maybeReplyToInbound({
+ ...BASE_ARGS,
+ inboundText: 'Can I talk to a human?',
+ callAssistant,
+ })
+
+ expect(callAssistant).not.toHaveBeenCalled()
+ expect(escalateConversation).toHaveBeenCalledWith(
+ expect.objectContaining({ reason: 'keyword' }),
+ )
+ })
+})
+
+describe('maybeReplyToInbound — daily cap → escalate', () => {
+ it('escalates with reason cap_reached when today’s replies hit the cap', async () => {
+ h.state.config = config({ daily_reply_cap: 5 })
+ h.state.repliesTodayCount = 5
+ const callAssistant = vi.fn()
+
+ await maybeReplyToInbound({
+ ...BASE_ARGS,
+ inboundText: 'what are your hours?',
+ callAssistant,
+ })
+
+ expect(callAssistant).not.toHaveBeenCalled()
+ expect(sendAiReply).not.toHaveBeenCalled()
+ expect(escalateConversation).toHaveBeenCalledWith(
+ expect.objectContaining({ reason: 'cap_reached' }),
+ )
+ expect(lastLog()).toMatchObject({ decision: 'escalated', reason: 'cap_reached' })
+ })
+
+ it('does NOT escalate on the cap when still below it', async () => {
+ h.state.config = config({ daily_reply_cap: 500 })
+ h.state.repliesTodayCount = 499
+ const callAssistant = vi.fn(async () => confidentResult())
+
+ await maybeReplyToInbound({
+ ...BASE_ARGS,
+ inboundText: 'what are your hours?',
+ callAssistant,
+ })
+
+ expect(callAssistant).toHaveBeenCalledTimes(1)
+ expect(sendAiReply).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('maybeReplyToInbound — low confidence → escalate', () => {
+ it('escalates with low_confidence and logs the model usage', async () => {
+ const callAssistant = vi.fn(async () => unsureResult())
+
+ await maybeReplyToInbound({
+ ...BASE_ARGS,
+ inboundText: 'do you offer financing?',
+ callAssistant,
+ })
+
+ expect(callAssistant).toHaveBeenCalledTimes(1)
+ expect(sendAiReply).not.toHaveBeenCalled()
+ expect(escalateConversation).toHaveBeenCalledWith(
+ expect.objectContaining({ reason: 'low_confidence' }),
+ )
+ expect(lastLog()).toMatchObject({
+ decision: 'escalated',
+ reason: 'low_confidence',
+ confident: false,
+ model: 'claude-sonnet-4-6',
+ input_tokens: 1200,
+ output_tokens: 10,
+ cache_read_tokens: 1000,
+ })
+ })
+})
+
+describe('maybeReplyToInbound — happy path → send + log', () => {
+ it('sends the reply and logs decision=replied with usage + latency', async () => {
+ const callAssistant = vi.fn(async () => confidentResult())
+
+ await maybeReplyToInbound({
+ ...BASE_ARGS,
+ inboundText: 'what are your opening hours?',
+ callAssistant,
+ })
+
+ expect(callAssistant).toHaveBeenCalledTimes(1)
+ // Sent over the bot path with the model's answer text.
+ expect(sendAiReply).toHaveBeenCalledTimes(1)
+ expect(sendAiReply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ accountId: 'acct-1',
+ conversationId: 'conv-1',
+ contactId: 'contact-1',
+ text: 'We are open 9am to 5pm, Monday to Friday.',
+ accessToken: 'token-xyz',
+ phoneNumberId: 'pn-1',
+ }),
+ )
+ expect(escalateConversation).not.toHaveBeenCalled()
+
+ const log = lastLog()
+ expect(log).toMatchObject({
+ decision: 'replied',
+ confident: true,
+ model: 'claude-sonnet-4-6',
+ input_tokens: 1200,
+ output_tokens: 40,
+ cache_read_tokens: 1000,
+ })
+ expect(typeof log.latency_ms).toBe('number')
+ expect(log.latency_ms as number).toBeGreaterThanOrEqual(0)
+ })
+
+ it('passes the assembled system + messages to the model wrapper', async () => {
+ const callAssistant = vi.fn(async (_args: unknown) => confidentResult())
+
+ await maybeReplyToInbound({
+ ...BASE_ARGS,
+ inboundText: 'what are your opening hours?',
+ callAssistant,
+ })
+
+ const arg = callAssistant.mock.calls[0][0] as {
+ model: string
+ system: { text: string }[]
+ messages: { role: string; content: string }[]
+ }
+ expect(arg.model).toBe('claude-sonnet-4-6')
+ // KB block is the last system block and carries the entry content.
+ expect(arg.system[arg.system.length - 1].text).toContain('Open 9-5 Mon-Fri.')
+ // The inbound text is the final user message.
+ const last = arg.messages[arg.messages.length - 1]
+ expect(last).toEqual({ role: 'user', content: 'what are your opening hours?' })
+ })
+})
+
+describe('maybeReplyToInbound — thrown error → escalate (never throws)', () => {
+ it('escalates with reason=error and logs when the model call throws', async () => {
+ const callAssistant = vi.fn(async () => {
+ throw new Error('anthropic 529 overloaded')
+ })
+
+ await expect(
+ maybeReplyToInbound({
+ ...BASE_ARGS,
+ inboundText: 'what are your hours?',
+ callAssistant,
+ }),
+ ).resolves.toBeUndefined()
+
+ expect(sendAiReply).not.toHaveBeenCalled()
+ expect(escalateConversation).toHaveBeenCalledWith(
+ expect.objectContaining({ reason: 'error' }),
+ )
+ expect(lastLog()).toMatchObject({ decision: 'error' })
+ expect((lastLog().reason as string) ?? '').toContain('anthropic 529 overloaded')
+ })
+
+ it('escalates on a send failure (sent decision but Meta/DB threw)', async () => {
+ sendAiReply.mockRejectedValueOnce(new Error('sent to Meta but DB insert failed'))
+ const callAssistant = vi.fn(async () => confidentResult())
+
+ await maybeReplyToInbound({
+ ...BASE_ARGS,
+ inboundText: 'what are your hours?',
+ callAssistant,
+ })
+
+ expect(escalateConversation).toHaveBeenCalledWith(
+ expect.objectContaining({ reason: 'error' }),
+ )
+ expect(lastLog()).toMatchObject({ decision: 'error' })
+ })
+
+ it('never throws even if the cap-count read fails', async () => {
+ h.state.failTable = 'ai_reply_log'
+ const callAssistant = vi.fn(async () => confidentResult())
+
+ // The first ai_reply_log access is the cap count (head select); forcing
+ // it to error sends the run down the catch path. Must resolve, not throw.
+ await expect(
+ maybeReplyToInbound({
+ ...BASE_ARGS,
+ inboundText: 'what are your hours?',
+ callAssistant,
+ }),
+ ).resolves.toBeUndefined()
+
+ expect(callAssistant).not.toHaveBeenCalled()
+ expect(escalateConversation).toHaveBeenCalledWith(
+ expect.objectContaining({ reason: 'error' }),
+ )
+ })
+})
diff --git a/src/lib/ai/reply.ts b/src/lib/ai/reply.ts
new file mode 100644
index 0000000000..c54f0304ad
--- /dev/null
+++ b/src/lib/ai/reply.ts
@@ -0,0 +1,386 @@
+/**
+ * The orchestrator (spec §5: `reply.ts`, §6 runtime flow).
+ *
+ * `maybeReplyToInbound` is the single entry point the WhatsApp webhook
+ * calls (fire-and-forget, after the inbound `messages` row is stored and
+ * the Flows dispatch resolves `!flowConsumed`). It composes the rest of
+ * `src/lib/ai/` into the gated decision pipeline of spec §6:
+ *
+ * load config → !enabled ─▶ skip
+ * load conversation → ai_handling === false ─▶ skip (human owns it)
+ * guardrails → keyword / "talk to human" ─▶ escalate(keyword), NO LLM call
+ * daily cap → today's `replied` >= cap ─▶ escalate(cap_reached)
+ * build prompt + call Claude (forced submit_answer)
+ * decide(result) → reply ─▶ send(bot) + log replied
+ * → else ─▶ escalate(low_confidence) + log escalated
+ * ANY throw anywhere ─▶ escalate(error) + log error
+ *
+ * The whole body is wrapped in try/catch and this function NEVER throws —
+ * any failure (missing API key, Anthropic error, malformed tool_use, DB
+ * hiccup, send failure) falls through to a fail-safe escalation to a
+ * human, honouring the "any doubt, any error → escalate, never go silent"
+ * bias of spec §1. The webhook already runs this off the ack path, so a
+ * slow or failing AI call can never delay the Meta 200.
+ *
+ * Everything that talks to the network or the DB lives in the thin
+ * sibling modules (`anthropic`, `send`, `escalate`, `config`,
+ * `knowledge-base`) and the service-role admin client — all mocked in
+ * `reply.test.ts`. `callAssistant` is injectable so the tests drive the
+ * model verdict without a network round-trip (spec §14).
+ */
+
+import { type AiReplyDecision } from '@/types'
+
+import { supabaseAdmin } from './admin-client'
+import {
+ callAssistant as defaultCallAssistant,
+ type CallAssistantArgs,
+ type CallAssistantResult,
+} from './anthropic'
+import { loadAiConfig } from './config'
+import { decide } from './decide'
+import { escalateConversation } from './escalate'
+import { shouldForceEscalate } from './guardrails'
+import { loadEnabledEntries } from './knowledge-base'
+import { buildMessages, buildSystemBlocks, type PromptHistoryMessage } from './prompt'
+import { sendAiReply } from './send'
+
+/** Arguments for {@link maybeReplyToInbound}. */
+export interface MaybeReplyToInboundArgs {
+ /** Tenancy key — scopes config, KB, cap count, contact + reply log. */
+ accountId: string
+ /** Conversation the inbound message belongs to. */
+ conversationId: string
+ /** Contact who sent the inbound message (send target + phone lookup). */
+ contactId: string
+ /** The inbound `messages.id` that triggered this evaluation (audit). */
+ messageId: string
+ /** The inbound customer text we are deciding how to answer. */
+ inboundText: string
+ /** Decrypted Meta access token, resolved by the webhook caller. */
+ accessToken: string
+ /** Meta phone-number id, resolved by the webhook caller. */
+ phoneNumberId: string
+ /**
+ * Injectable model wrapper (default the real {@link defaultCallAssistant}).
+ * Tests pass a stub so no network call happens (spec §14).
+ */
+ callAssistant?: (args: CallAssistantArgs) => Promise
+}
+
+/**
+ * How many recent conversation messages to load for the model's context.
+ * The prompt assembler caps this again at {@link HISTORY_MESSAGE_LIMIT}
+ * (default 10) after dropping empty turns; we over-fetch slightly so a
+ * couple of media/empty rows don't starve the usable history window.
+ */
+const HISTORY_FETCH_LIMIT = 20
+
+/** Fields recorded for one `ai_reply_log` row, beyond the always-set ones. */
+interface LogAiReplyArgs {
+ accountId: string
+ conversationId: string
+ messageId: string
+ decision: AiReplyDecision
+ /** Model self-report; omit (null) when the decision was made pre-LLM. */
+ confident?: boolean
+ /** Escalation reason / error summary. */
+ reason?: string
+ model?: string
+ input_tokens?: number
+ output_tokens?: number
+ cache_read_tokens?: number
+ latency_ms?: number
+}
+
+/**
+ * Insert one audit row into `ai_reply_log` via the service-role client
+ * (RLS-bypassing, like the rest of the AI engine — spec §4.4 inserts are
+ * service-role only). Best-effort: a logging failure is swallowed so it
+ * can never turn a successful reply/escalation into a thrown error. The
+ * orchestrator's own try/catch is the safety net for the real work.
+ */
+async function logAiReply(args: LogAiReplyArgs): Promise {
+ try {
+ const db = supabaseAdmin()
+ const { error } = await db.from('ai_reply_log').insert({
+ account_id: args.accountId,
+ conversation_id: args.conversationId,
+ message_id: args.messageId,
+ decision: args.decision,
+ confident: args.confident ?? null,
+ reason: args.reason ?? null,
+ model: args.model ?? null,
+ input_tokens: args.input_tokens ?? null,
+ output_tokens: args.output_tokens ?? null,
+ cache_read_tokens: args.cache_read_tokens ?? null,
+ latency_ms: args.latency_ms ?? null,
+ })
+ if (error) {
+ console.error('[ai] logAiReply insert error:', error.message)
+ }
+ } catch (err) {
+ console.error(
+ '[ai] logAiReply failed:',
+ err instanceof Error ? err.message : err,
+ )
+ }
+}
+
+/** Count today's autonomous replies for an account (UTC-midnight window). */
+async function countRepliesToday(accountId: string): Promise {
+ const db = supabaseAdmin()
+ const since = startOfUtcDay()
+ const { count, error } = await db
+ .from('ai_reply_log')
+ .select('id', { count: 'exact', head: true })
+ .eq('account_id', accountId)
+ .eq('decision', 'replied')
+ .gte('created_at', since)
+ if (error) {
+ // A failed cap read must not let us blow past the cap silently. Treat
+ // it as "can't verify" and surface it — the orchestrator's catch turns
+ // an unverifiable cap into a fail-safe escalation rather than a send.
+ throw new Error(`daily cap count failed: ${error.message}`)
+ }
+ return count ?? 0
+}
+
+/** ISO timestamp for 00:00:00 UTC today — the daily-cap window start. */
+function startOfUtcDay(): string {
+ const now = new Date()
+ return new Date(
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
+ ).toISOString()
+}
+
+/**
+ * Load the recent conversation history projected to the minimal
+ * {@link PromptHistoryMessage} shape `prompt.ts` consumes. Oldest→newest.
+ * Returns `[]` on any DB error — an empty history is harmless (the model
+ * still has the inbound text + the KB), so we don't fail the whole run.
+ */
+async function loadHistory(
+ conversationId: string,
+): Promise {
+ const db = supabaseAdmin()
+ const { data, error } = await db
+ .from('messages')
+ .select('sender_type, content_text, created_at')
+ .eq('conversation_id', conversationId)
+ .order('created_at', { ascending: false })
+ .limit(HISTORY_FETCH_LIMIT)
+ if (error) {
+ console.error('[ai] loadHistory error:', error.message)
+ return []
+ }
+ const rows = (data as PromptHistoryMessage[] | null) ?? []
+ // Fetched newest→oldest for the LIMIT; reverse to oldest→newest for the
+ // prompt (buildMessages expects chronological order).
+ return rows.slice().reverse()
+}
+
+/** Load the contact's phone (account-scoped) for the escalation handoff send. */
+async function loadContactPhone(
+ accountId: string,
+ contactId: string,
+): Promise {
+ const db = supabaseAdmin()
+ const { data } = await db
+ .from('contacts')
+ .select('phone')
+ .eq('id', contactId)
+ .eq('account_id', accountId)
+ .maybeSingle()
+ return (data as { phone?: string } | null)?.phone ?? ''
+}
+
+/**
+ * Evaluate one inbound customer message and either reply autonomously or
+ * escalate to a human (spec §6). Fire-and-forget from the webhook; this
+ * function NEVER throws — every failure path escalates and logs.
+ */
+export async function maybeReplyToInbound(
+ args: MaybeReplyToInboundArgs,
+): Promise {
+ const {
+ accountId,
+ conversationId,
+ contactId,
+ messageId,
+ inboundText,
+ accessToken,
+ phoneNumberId,
+ } = args
+ const callAssistant = args.callAssistant ?? defaultCallAssistant
+
+ try {
+ // 1. Load config. Missing / disabled config → AI is off for this
+ // account; skip silently (spec §6: `!enabled → skip`).
+ const config = await loadAiConfig(accountId)
+ if (!config || !config.enabled) {
+ await logAiReply({
+ accountId,
+ conversationId,
+ messageId,
+ decision: 'skipped',
+ reason: !config ? 'no_config' : 'disabled',
+ })
+ return
+ }
+
+ // 2. Load the conversation. If a human has taken over (ai_handling ===
+ // false) the AI stays silent (spec §6: human owns it → skip).
+ const db = supabaseAdmin()
+ const { data: conversation } = await db
+ .from('conversations')
+ .select('id, ai_handling')
+ .eq('id', conversationId)
+ .maybeSingle()
+ if (conversation && (conversation as { ai_handling?: boolean }).ai_handling === false) {
+ await logAiReply({
+ accountId,
+ conversationId,
+ messageId,
+ decision: 'skipped',
+ reason: 'human_takeover',
+ })
+ return
+ }
+
+ // The customer phone is needed only by escalate's optional handoff
+ // send; resolve it once up front so every escalation branch can use it.
+ const customerPhone = await loadContactPhone(accountId, contactId)
+ const escalate = (reason: 'keyword' | 'cap_reached' | 'low_confidence' | 'error') =>
+ escalateConversation({
+ conversationId,
+ reason,
+ config,
+ accessToken,
+ phoneNumberId,
+ customerPhone,
+ })
+
+ // 3. Deterministic guardrails — escalate on a keyword / explicit human
+ // request WITHOUT calling the model (spec §6, cheaper + immune to a
+ // confused model).
+ const guard = shouldForceEscalate(inboundText, config.escalation_keywords)
+ if (guard.escalate) {
+ await escalate('keyword')
+ await logAiReply({
+ accountId,
+ conversationId,
+ messageId,
+ decision: 'escalated',
+ reason: 'keyword',
+ })
+ return
+ }
+
+ // 4. Daily reply cap — escalate when today's autonomous replies have
+ // hit the per-account cap (spec §6, §13). Counted pre-LLM so a
+ // capped account never even calls the model.
+ const repliesToday = await countRepliesToday(accountId)
+ if (repliesToday >= config.daily_reply_cap) {
+ await escalate('cap_reached')
+ await logAiReply({
+ accountId,
+ conversationId,
+ messageId,
+ decision: 'escalated',
+ reason: 'cap_reached',
+ })
+ return
+ }
+
+ // 5. Build the prompt (persona + cached KB block + recent history +
+ // inbound) and call Claude through the forced `submit_answer` tool.
+ const [kbEntries, history] = await Promise.all([
+ loadEnabledEntries(accountId),
+ loadHistory(conversationId),
+ ])
+ const system = buildSystemBlocks(config, kbEntries)
+ const messages = buildMessages(history, inboundText)
+
+ const startedAt = Date.now()
+ const result = await callAssistant({ model: config.model, system, messages })
+ const latencyMs = Date.now() - startedAt
+
+ // 6. Reduce the model's structured result to a reply / escalate verdict.
+ const decision = decide(result)
+ if (decision.action === 'reply') {
+ await sendAiReply({
+ accountId,
+ conversationId,
+ contactId,
+ text: decision.text,
+ accessToken,
+ phoneNumberId,
+ })
+ await logAiReply({
+ accountId,
+ conversationId,
+ messageId,
+ decision: 'replied',
+ confident: result.confident,
+ reason: result.reason,
+ model: config.model,
+ input_tokens: result.usage.input_tokens,
+ output_tokens: result.usage.output_tokens,
+ cache_read_tokens: result.usage.cache_read_input_tokens,
+ latency_ms: latencyMs,
+ })
+ return
+ }
+
+ // Not confident (or confident-but-empty) → hand to a human.
+ await escalate('low_confidence')
+ await logAiReply({
+ accountId,
+ conversationId,
+ messageId,
+ decision: 'escalated',
+ confident: result.confident,
+ reason: 'low_confidence',
+ model: config.model,
+ input_tokens: result.usage.input_tokens,
+ output_tokens: result.usage.output_tokens,
+ cache_read_tokens: result.usage.cache_read_input_tokens,
+ latency_ms: latencyMs,
+ })
+ } catch (err) {
+ // 7. Fail safe to a human (spec §1, §6). ANY throw above — missing API
+ // key, Anthropic error, malformed tool_use, send failure, DB hiccup
+ // — lands here. Escalate and log; never rethrow.
+ const summary = err instanceof Error ? err.message : String(err)
+ console.error('[ai] maybeReplyToInbound error:', summary)
+ try {
+ const config = await loadAiConfig(accountId)
+ const customerPhone = await loadContactPhone(accountId, contactId)
+ if (config) {
+ await escalateConversation({
+ conversationId,
+ reason: 'error',
+ config,
+ accessToken,
+ phoneNumberId,
+ customerPhone,
+ })
+ }
+ } catch (escErr) {
+ // Even the fail-safe escalation failed — log and swallow. The webhook
+ // must not see this function throw under any circumstances.
+ console.error(
+ '[ai] maybeReplyToInbound escalation-on-error failed:',
+ escErr instanceof Error ? escErr.message : escErr,
+ )
+ }
+ await logAiReply({
+ accountId,
+ conversationId,
+ messageId,
+ decision: 'error',
+ reason: summary,
+ })
+ }
+}
diff --git a/src/lib/ai/send.ts b/src/lib/ai/send.ts
new file mode 100644
index 0000000000..1b2a2b4085
--- /dev/null
+++ b/src/lib/ai/send.ts
@@ -0,0 +1,146 @@
+/**
+ * Outbound AI reply sender (spec §5: `send.ts`).
+ *
+ * Sends one autonomous AI reply to the customer over the SAME Meta Cloud
+ * API path the inbox composer (`POST /api/whatsapp/send`) and the Flows
+ * engine (`src/lib/flows/meta-send.ts`) already use — `sendTextMessage`
+ * from `src/lib/whatsapp/meta-api.ts` — then persists the outgoing row to
+ * `messages` with `sender_type='bot'` so the inbox renders it with the
+ * "AI" tag (spec §8) and the conversation list preview updates.
+ *
+ * The orchestrator (`reply.ts`) has already loaded + decrypted the account's
+ * `whatsapp_config`, so the resolved `accessToken` + `phoneNumberId` are
+ * passed in directly rather than re-fetched here. This keeps the single
+ * config read on the hot path in one place and lets the handoff send in
+ * `escalate.ts` reuse the same already-decrypted token.
+ *
+ * Mirrors `engineSendText` in `src/lib/flows/meta-send.ts`: same
+ * phone-variant retry (`phoneVariants` + `isRecipientNotAllowedError`),
+ * the same auto-correct-contact-phone-on-success step, and the same
+ * "sent to Meta but DB insert failed" hard error. The only differences
+ * are the pre-resolved credentials and `sender_type='bot'` for an AI send.
+ *
+ * Throws on any failure (contact missing, all phone variants rejected,
+ * DB insert failed) so the caller's try/catch can fall back to escalation
+ * — the "fail safe to a human" bias of spec §1 / §6.
+ */
+
+import {
+ sanitizePhoneForMeta,
+ isValidE164,
+ phoneVariants,
+ isRecipientNotAllowedError,
+} from '@/lib/whatsapp/phone-utils'
+import { sendTextMessage } from '@/lib/whatsapp/meta-api'
+
+import { supabaseAdmin } from './admin-client'
+
+export interface SendAiReplyArgs {
+ /** Account-level tenancy key — scopes the contact lookup + auto-correct. */
+ accountId: string
+ /** Conversation the reply belongs to; the outgoing row + preview attach here. */
+ conversationId: string
+ /** Contact whose phone the message is sent to (resolved + validated here). */
+ contactId: string
+ /** The reply text to send the customer (already decided non-empty upstream). */
+ text: string
+ /** Decrypted Meta access token, resolved by the caller from whatsapp_config. */
+ accessToken: string
+ /** Meta phone-number id, resolved by the caller from whatsapp_config. */
+ phoneNumberId: string
+}
+
+/**
+ * Send an AI-authored text reply to the customer and persist it.
+ *
+ * Returns the Meta message id on success. The bot's message lands in
+ * `messages` with `sender_type='bot'`, `content_type='text'`,
+ * `status='sent'`, exactly like the Flows engine's text sends — so the
+ * "AI" tag in the inbox keys off the same `sender_type='bot'` discriminator.
+ */
+export async function sendAiReply(
+ args: SendAiReplyArgs,
+): Promise<{ whatsapp_message_id: string }> {
+ const db = supabaseAdmin()
+
+ // Resolve + validate the contact phone. Scoped by account_id for the
+ // same defense-in-depth reason as flows/automations meta-send.
+ const { data: contact, error: contactErr } = await db
+ .from('contacts')
+ .select('id, phone')
+ .eq('id', args.contactId)
+ .eq('account_id', args.accountId)
+ .maybeSingle()
+ if (contactErr || !contact?.phone) {
+ throw new Error('contact not found for this account')
+ }
+
+ const sanitized = sanitizePhoneForMeta(contact.phone)
+ if (!isValidE164(sanitized)) {
+ throw new Error(`contact phone invalid: ${contact.phone}`)
+ }
+
+ const attempt = async (phone: string): Promise => {
+ const r = await sendTextMessage({
+ phoneNumberId: args.phoneNumberId,
+ accessToken: args.accessToken,
+ to: phone,
+ text: args.text,
+ })
+ return r.messageId
+ }
+
+ // Phone-variant retry: numbers registered with/without a trunk 0 plus
+ // Meta's sandbox quirks need this to reliably land. Only the specific
+ // "recipient not in allowed list" failure is retried; any other error
+ // (bad token, invalid recipient) bubbles up so the caller escalates.
+ const variants = phoneVariants(sanitized)
+ let workingPhone = sanitized
+ let waMessageId = ''
+ let lastError: unknown = null
+ for (const v of variants) {
+ try {
+ waMessageId = await attempt(v)
+ workingPhone = v
+ lastError = null
+ break
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err)
+ if (!isRecipientNotAllowedError(msg)) throw err
+ lastError = err
+ }
+ }
+ if (lastError) throw lastError
+
+ // Persist the working variant back to the contact so future sends go
+ // straight through on the first attempt.
+ if (workingPhone !== sanitized) {
+ await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id)
+ }
+
+ // Persist the bot's reply. sender_type='bot' is what the inbox keys the
+ // "AI" tag off (spec §8); content_type='text'/status='sent' match the
+ // composer + flows text sends (migration 001 messages schema).
+ const { error: msgErr } = await db.from('messages').insert({
+ conversation_id: args.conversationId,
+ sender_type: 'bot',
+ content_type: 'text',
+ content_text: args.text,
+ message_id: waMessageId,
+ status: 'sent',
+ })
+ if (msgErr) {
+ throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)
+ }
+
+ await db
+ .from('conversations')
+ .update({
+ last_message_text: args.text,
+ last_message_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ })
+ .eq('id', args.conversationId)
+
+ return { whatsapp_message_id: waMessageId }
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 66e0b8a958..5a0e2d3113 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -156,6 +156,23 @@ export interface Conversation {
created_at: string;
updated_at: string;
contact?: Contact;
+ /**
+ * Is the AI assistant still driving this conversation? Added by
+ * `027_ai_assistant.sql` with DB default `true`, so a freshly-created
+ * conversation is eligible for the bot. Flipped to `false` on
+ * escalation or the instant a human replies (human-takeover). When
+ * `false` the assistant stays silent on this thread.
+ */
+ ai_handling?: boolean;
+ /** Set when the assistant handed this conversation off to a human. */
+ ai_escalated_at?: string;
+ /**
+ * Why the assistant escalated:
+ * `low_confidence` | `keyword` | `error` | `cap_reached` | `human_takeover`.
+ * Free-form text in the DB; this union documents the values the
+ * engine writes.
+ */
+ ai_escalation_reason?: AiEscalationReason;
}
export type SenderType = 'customer' | 'agent' | 'bot';
@@ -542,3 +559,139 @@ export interface AutomationLog {
created_at: string;
contact?: Contact;
}
+
+// ============================================================
+// AI assistant (027_ai_assistant.sql)
+// ============================================================
+
+/**
+ * Per-account configuration for the WhatsApp AI assistant ("LLM
+ * wiki"). Exactly one row per account (UNIQUE account_id). Off by
+ * default — the assistant is strictly opt-in. Settings-class:
+ * readable/writable by admin+ only. The `ANTHROPIC_API_KEY` itself is
+ * NEVER on this type — it lives server-side in env; Settings reports
+ * only a configured/not-configured boolean.
+ */
+export interface AiAssistantConfig {
+ id: string;
+ /** Tenancy key — one config per account (NOT NULL, UNIQUE in the DB). */
+ account_id: string;
+ /** Master switch. DB default `false` (opt-in). */
+ enabled: boolean;
+ /**
+ * Editable system prompt. Seeded with the strong "answer ONLY from
+ * the KB" default (see spec §7.2); `{business_name}` is substituted
+ * at prompt-assembly time.
+ */
+ system_prompt: string;
+ /** Sent to the customer on escalation; nullable = send nothing. */
+ handoff_message?: string;
+ /**
+ * Keywords that always escalate to a human pre-LLM (no model call).
+ * Seeded with: refund, cancel, complaint, lawyer, legal, human,
+ * agent, manager. DB default `'{}'`-typed but seeded non-empty.
+ */
+ escalation_keywords: string[];
+ /** Persona context (business name) injected into the prompt. */
+ business_name?: string;
+ /**
+ * Stored via the existing storage bucket; persona context only in
+ * v1 (no customer-facing surface yet).
+ */
+ logo_url?: string;
+ /** Anthropic model id. DB default `'claude-sonnet-4-6'`. */
+ model: string;
+ /** Per-account daily reply cap; cap hit → escalate. DB default 500. */
+ daily_reply_cap: number;
+ created_at: string;
+ updated_at: string;
+}
+
+/** Where a knowledge-base entry came from. */
+export type KnowledgeBaseSourceType = 'manual' | 'file';
+
+/**
+ * One "wiki page" of the per-account knowledge base. The model is fed
+ * the concatenation of every `enabled` entry for the account; disabled
+ * entries are excluded from the prompt. Settings-class: admin+ only.
+ */
+export interface KnowledgeBaseEntry {
+ id: string;
+ /** Tenancy key — strict per-account isolation in prompt assembly. */
+ account_id: string;
+ title: string;
+ /** Markdown / plain text fed verbatim to the model. */
+ content: string;
+ /** Hand-authored (`manual`) vs created from an upload (`file`). */
+ source_type: KnowledgeBaseSourceType;
+ /** Original filename when `source_type === 'file'`. */
+ source_filename?: string;
+ /** Disabled entries are excluded from the assembled prompt. */
+ enabled: boolean;
+ /**
+ * Cached ~token count (chars/4 heuristic) for the Settings size
+ * meter. Nullable until first computed.
+ */
+ token_estimate?: number;
+ /** Author, for audit. NULL after the user is deleted (ON DELETE SET NULL). */
+ created_by_user_id?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+/** Outcome recorded for one evaluated inbound message. */
+export type AiReplyDecision = 'replied' | 'escalated' | 'skipped' | 'error';
+
+/**
+ * Why the assistant escalated a conversation to a human. Written to
+ * both `conversations.ai_escalation_reason` and `ai_reply_log.reason`.
+ */
+export type AiEscalationReason =
+ | 'low_confidence'
+ | 'keyword'
+ | 'error'
+ | 'cap_reached'
+ | 'human_takeover';
+
+/**
+ * One row per AI decision — audit trail and the data source for a
+ * future cost view (spec §16; no UI reads it in v1). Inserted by the
+ * webhook engine via the service-role client (RLS-bypassing);
+ * admin-read only.
+ */
+export interface AiReplyLog {
+ id: string;
+ account_id: string;
+ conversation_id?: string;
+ /** The inbound `messages.id` that triggered this decision. */
+ message_id?: string;
+ decision: AiReplyDecision;
+ /** Model's self-report; NULL when the decision was made pre-LLM. */
+ confident?: boolean;
+ /** Escalation reason / error summary. */
+ reason?: string;
+ model?: string;
+ /** Token usage from the Anthropic `usage` block; NULL when no LLM call. */
+ input_tokens?: number;
+ output_tokens?: number;
+ cache_read_tokens?: number;
+ latency_ms?: number;
+ created_at: string;
+}
+
+/**
+ * The structured result the model returns via the forced
+ * `submit_answer` tool (spec §7.1), shared between the Anthropic
+ * wrapper, the pure `decide` core, and their tests. The deterministic
+ * guardrails and `decide` consume this to choose reply vs. escalate;
+ * auto-send requires BOTH `confident === true` and a non-empty
+ * `answer`.
+ */
+export interface AiModelResult {
+ /** The reply to send the customer; empty when not confident. */
+ answer: string;
+ /** True ONLY if the knowledge base fully answers the question. */
+ confident: boolean;
+ /** Short rationale, recorded in the audit log. */
+ reason: string;
+}
diff --git a/supabase/migrations/027_ai_assistant.sql b/supabase/migrations/027_ai_assistant.sql
new file mode 100644
index 0000000000..69cb4b467e
--- /dev/null
+++ b/supabase/migrations/027_ai_assistant.sql
@@ -0,0 +1,214 @@
+-- ============================================================
+-- 027_ai_assistant.sql — AI assistant for WhatsApp ("LLM wiki")
+--
+-- Adds the data model for an AI assistant that answers inbound
+-- WhatsApp messages grounded only in a per-account knowledge base.
+-- When the model is confident the KB covers the question it replies
+-- autonomously; otherwise it escalates to a human and goes silent on
+-- that conversation. See docs/superpowers/specs/2026-06-25-wacrm-ai-
+-- assistant-design.md §4 for the source-of-truth design.
+--
+-- What this migration does
+-- 1. `ai_assistant_config` — one row per account: master enable,
+-- editable system prompt, persona, model, daily reply cap.
+-- 2. `knowledge_base_entries` — the "wiki pages" fed to the model.
+-- 3. ALTERs `conversations` with the AI hand-off columns
+-- (`ai_handling`, `ai_escalated_at`, `ai_escalation_reason`).
+-- 4. `ai_reply_log` — one row per AI decision (audit + future cost
+-- view). Inserts are service-role only.
+--
+-- Tenancy + RLS follow migrations 017–026: every table carries an
+-- `account_id` FK to `accounts` (ON DELETE CASCADE), RLS is enabled,
+-- and policies gate access via the `is_account_member(account_id,
+-- min_role)` SECURITY DEFINER helper from 017. Config + knowledge
+-- are admin+ (settings-class, mirroring `tags` / `api_keys`).
+-- `ai_reply_log` is admin-read, service-role-write (mirroring
+-- `automation_logs`). Service-role writes from the webhook bypass
+-- RLS as the other engines already do.
+--
+-- Idempotent — safe to run multiple times. Tables use IF NOT
+-- EXISTS; columns use ADD COLUMN IF NOT EXISTS; policies are dropped
+-- before recreate (Postgres has no CREATE POLICY IF NOT EXISTS).
+-- ============================================================
+
+-- ============================================================
+-- AI_ASSISTANT_CONFIG — one row per account
+--
+-- One config row per account (UNIQUE account_id). `enabled` is off
+-- by default: the assistant is strictly opt-in. `system_prompt` is
+-- seeded with the strong "answer ONLY from the KB" default from
+-- §7.2; admins may edit it. `escalation_keywords` is seeded with the
+-- safety vocabulary that always hands off to a human pre-LLM.
+-- `logo_url` is persona context only in v1 (no customer-facing
+-- surface yet).
+-- ============================================================
+CREATE TABLE IF NOT EXISTS ai_assistant_config (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ account_id UUID NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
+ enabled BOOLEAN NOT NULL DEFAULT FALSE,
+ system_prompt TEXT NOT NULL DEFAULT
+ 'You are the customer-support assistant for {business_name}. Answer ONLY using the information in the KNOWLEDGE BASE below. If the knowledge base does not clearly and fully answer the customer''s question, you MUST set `confident` to false and leave `answer` empty — do not guess, do not use outside knowledge, do not make promises about pricing, refunds, delivery dates, or policies that aren''t written here. Be concise, friendly, and match the customer''s language.',
+ handoff_message TEXT,
+ escalation_keywords TEXT[] NOT NULL DEFAULT
+ '{refund,cancel,complaint,lawyer,legal,human,agent,manager}',
+ business_name TEXT,
+ logo_url TEXT,
+ model TEXT NOT NULL DEFAULT 'claude-sonnet-4-6',
+ daily_reply_cap INTEGER NOT NULL DEFAULT 500,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- account_id is the lookup key (one row per account; loaded on every
+-- inbound the assistant evaluates). UNIQUE already creates an index,
+-- but spell it out so intent survives a future drop of the UNIQUE.
+CREATE INDEX IF NOT EXISTS idx_ai_assistant_config_account
+ ON ai_assistant_config(account_id);
+
+ALTER TABLE ai_assistant_config ENABLE ROW LEVEL SECURITY;
+
+DROP TRIGGER IF EXISTS set_updated_at ON ai_assistant_config;
+CREATE TRIGGER set_updated_at BEFORE UPDATE ON ai_assistant_config
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- RLS: settings-class. admin+ for select/insert/update (mirrors the
+-- `tags` / `whatsapp_config` policies in 017). No DELETE policy —
+-- the row is removed only by the account cascade.
+DROP POLICY IF EXISTS ai_assistant_config_select ON ai_assistant_config;
+CREATE POLICY ai_assistant_config_select ON ai_assistant_config FOR SELECT
+ USING (is_account_member(account_id, 'admin'));
+
+DROP POLICY IF EXISTS ai_assistant_config_insert ON ai_assistant_config;
+CREATE POLICY ai_assistant_config_insert ON ai_assistant_config FOR INSERT
+ WITH CHECK (is_account_member(account_id, 'admin'));
+
+DROP POLICY IF EXISTS ai_assistant_config_update ON ai_assistant_config;
+CREATE POLICY ai_assistant_config_update ON ai_assistant_config FOR UPDATE
+ USING (is_account_member(account_id, 'admin'))
+ WITH CHECK (is_account_member(account_id, 'admin'));
+
+-- ============================================================
+-- KNOWLEDGE_BASE_ENTRIES — many per account (the "wiki pages")
+--
+-- Each row is one page of the LLM wiki. `content` is the markdown /
+-- plain text fed verbatim to the model. `source_type` distinguishes
+-- a hand-authored entry from one created by a file upload (the
+-- original filename is kept for display). Disabled entries are
+-- excluded from the assembled prompt. `token_estimate` caches a
+-- chars/4 heuristic for the Settings size meter.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS knowledge_base_entries (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+ source_type TEXT NOT NULL DEFAULT 'manual' CHECK (source_type IN ('manual', 'file')),
+ source_filename TEXT,
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ token_estimate INTEGER,
+ created_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- account_id: prompt assembly loads "this account's enabled entries".
+-- The partial composite is tuned for that hot path (enabled only).
+CREATE INDEX IF NOT EXISTS idx_knowledge_base_entries_account
+ ON knowledge_base_entries(account_id);
+CREATE INDEX IF NOT EXISTS idx_knowledge_base_entries_account_enabled
+ ON knowledge_base_entries(account_id)
+ WHERE enabled = TRUE;
+
+ALTER TABLE knowledge_base_entries ENABLE ROW LEVEL SECURITY;
+
+DROP TRIGGER IF EXISTS set_updated_at ON knowledge_base_entries;
+CREATE TRIGGER set_updated_at BEFORE UPDATE ON knowledge_base_entries
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+-- RLS: settings-class. All ops admin+ (mirrors `custom_fields` in 017).
+DROP POLICY IF EXISTS knowledge_base_entries_select ON knowledge_base_entries;
+CREATE POLICY knowledge_base_entries_select ON knowledge_base_entries FOR SELECT
+ USING (is_account_member(account_id, 'admin'));
+
+DROP POLICY IF EXISTS knowledge_base_entries_insert ON knowledge_base_entries;
+CREATE POLICY knowledge_base_entries_insert ON knowledge_base_entries FOR INSERT
+ WITH CHECK (is_account_member(account_id, 'admin'));
+
+DROP POLICY IF EXISTS knowledge_base_entries_update ON knowledge_base_entries;
+CREATE POLICY knowledge_base_entries_update ON knowledge_base_entries FOR UPDATE
+ USING (is_account_member(account_id, 'admin'))
+ WITH CHECK (is_account_member(account_id, 'admin'));
+
+DROP POLICY IF EXISTS knowledge_base_entries_delete ON knowledge_base_entries;
+CREATE POLICY knowledge_base_entries_delete ON knowledge_base_entries FOR DELETE
+ USING (is_account_member(account_id, 'admin'));
+
+-- ============================================================
+-- CONVERSATIONS — AI hand-off columns (ALTER)
+--
+-- `ai_handling` is the "is the bot still driving this thread?" flag.
+-- Defaults TRUE so a freshly-created conversation is eligible for the
+-- assistant; set FALSE on escalation or the instant a human replies
+-- (human-takeover detection in the send route). `ai_escalated_at` /
+-- `ai_escalation_reason` record the hand-off for the inbox badge.
+--
+-- No change to the `status` CHECK: on escalation the engine also sets
+-- status='pending', already an allowed value that reads as "needs
+-- attention" in the inbox.
+-- ============================================================
+ALTER TABLE conversations
+ ADD COLUMN IF NOT EXISTS ai_handling BOOLEAN NOT NULL DEFAULT TRUE,
+ ADD COLUMN IF NOT EXISTS ai_escalated_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS ai_escalation_reason TEXT;
+
+-- Surfaces the "🙋 Needs human" list filter: escalated conversations
+-- still awaiting a human. Partial index keeps it small.
+CREATE INDEX IF NOT EXISTS idx_conversations_ai_escalated
+ ON conversations(account_id, ai_escalated_at)
+ WHERE ai_escalated_at IS NOT NULL;
+
+-- ============================================================
+-- AI_REPLY_LOG — one row per AI decision (audit + future cost view)
+--
+-- Written by the webhook engine via the service-role client on every
+-- evaluated inbound: a reply sent, an escalation, a pre-LLM skip, or
+-- an error. `confident` is the model's self-report (NULL when the
+-- decision was made before any LLM call — e.g. a keyword/cap skip).
+-- The token + latency columns feed a future cost dashboard (§16); no
+-- UI reads them in v1.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS ai_reply_log (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
+ conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
+ message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
+ decision TEXT NOT NULL CHECK (decision IN ('replied', 'escalated', 'skipped', 'error')),
+ confident BOOLEAN,
+ reason TEXT,
+ model TEXT,
+ input_tokens INTEGER,
+ output_tokens INTEGER,
+ cache_read_tokens INTEGER,
+ latency_ms INTEGER,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- account_id: the future cost view and the daily-cap count both scope
+-- by account. The cap check counts today's `replied` rows, so a
+-- composite on (account_id, created_at) WHERE decision='replied'
+-- answers it from one index lookup.
+CREATE INDEX IF NOT EXISTS idx_ai_reply_log_account
+ ON ai_reply_log(account_id);
+CREATE INDEX IF NOT EXISTS idx_ai_reply_log_account_replied
+ ON ai_reply_log(account_id, created_at)
+ WHERE decision = 'replied';
+
+ALTER TABLE ai_reply_log ENABLE ROW LEVEL SECURITY;
+
+-- RLS: admin-read, service-role-write (mirrors `automation_logs` in
+-- 017). The webhook engine inserts with the service-role client,
+-- which bypasses RLS — so there is no client INSERT/UPDATE/DELETE
+-- policy.
+DROP POLICY IF EXISTS ai_reply_log_select ON ai_reply_log;
+CREATE POLICY ai_reply_log_select ON ai_reply_log FOR SELECT
+ USING (is_account_member(account_id, 'admin'));
From ed4619d97f0da7555aa78148780d497b6ed5d6bc Mon Sep 17 00:00:00 2001
From: Martinxmaina
Date: Thu, 25 Jun 2026 21:33:27 +0300
Subject: [PATCH 3/8] =?UTF-8?q?feat(ai):=20/api/ai=20routes=20=E2=80=94=20?=
=?UTF-8?q?config=20+=20knowledge=20base=20CRUD=20+=20file=20upload?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Admin-gated (requireRole('admin') + canEditSettings + RLS), rate-limited,
input-bounded dashboard endpoints (spec §10):
- GET/PUT /api/ai/config (seeds default row; returns apiKeyConfigured
boolean, never the key)
- GET/POST /api/ai/knowledge ; PATCH/DELETE /api/ai/knowledge/[id]
- POST /api/ai/knowledge/upload (.txt/.md direct, PDF via unpdf,
runtime=nodejs, 10MB cap, 422 on extraction failure)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/app/api/ai/config/route.ts | 396 +++++++++++++++++++++++
src/app/api/ai/knowledge/[id]/route.ts | 229 +++++++++++++
src/app/api/ai/knowledge/route.ts | 150 +++++++++
src/app/api/ai/knowledge/upload/route.ts | 219 +++++++++++++
4 files changed, 994 insertions(+)
create mode 100644 src/app/api/ai/config/route.ts
create mode 100644 src/app/api/ai/knowledge/[id]/route.ts
create mode 100644 src/app/api/ai/knowledge/route.ts
create mode 100644 src/app/api/ai/knowledge/upload/route.ts
diff --git a/src/app/api/ai/config/route.ts b/src/app/api/ai/config/route.ts
new file mode 100644
index 0000000000..3528fec64f
--- /dev/null
+++ b/src/app/api/ai/config/route.ts
@@ -0,0 +1,396 @@
+// ============================================================
+// /api/ai/config
+//
+// GET — read this account's AI assistant config (spec §4.1, §9,
+// §10). Seeds a default row the first time the AI Settings
+// tab is opened (mirroring how other settings rows are
+// lazily created), so the UI always gets a config to bind.
+// Adds a server-computed `apiKeyConfigured` boolean —
+// whether `ANTHROPIC_API_KEY` is set in the environment.
+// The key ITSELF is never returned (spec §9.1, §12).
+// PUT — update the editable fields. Admin+ only.
+//
+// Both verbs go through the cookie-session Supabase server client,
+// so RLS enforces tenancy. On top of that the `ai_assistant_config`
+// RLS policies are admin-only (migration 027), but we ALSO gate the
+// route with `requireRole('admin')` — the same belt-and-braces
+// pattern as `/api/account/api-keys` (explicit role check + RLS),
+// so an under-privileged caller gets a clean 403 instead of an
+// opaque empty result from RLS.
+// ============================================================
+
+import { NextResponse } from 'next/server';
+
+import { requireRole, toErrorResponse } from '@/lib/auth/account';
+import {
+ checkRateLimit,
+ rateLimitResponse,
+ RATE_LIMITS,
+} from '@/lib/rate-limit';
+import type { AiAssistantConfig } from '@/types';
+
+// Columns the client may read. Mirrors `AiAssistantConfig`; there is
+// no secret column on this table (the Anthropic key lives in env, not
+// the DB), so we select everything the row holds.
+const CONFIG_COLUMNS =
+ 'id, account_id, enabled, system_prompt, handoff_message, escalation_keywords, business_name, logo_url, model, daily_reply_cap, created_at, updated_at';
+
+// Bounds on caller-supplied free text, so a runaway paste can't bloat
+// the row (and, downstream, the prompt). Generous — these are admin
+// settings, not user input.
+const MAX_PROMPT_LEN = 20_000;
+const MAX_HANDOFF_LEN = 2_000;
+const MAX_BUSINESS_NAME_LEN = 200;
+const MAX_LOGO_URL_LEN = 2_000;
+const MAX_MODEL_LEN = 100;
+const MAX_KEYWORDS = 100;
+const MAX_KEYWORD_LEN = 80;
+// Daily reply cap is a positive integer; clamp the ceiling so a typo
+// can't disable the cost guard entirely.
+const MAX_DAILY_REPLY_CAP = 100_000;
+
+/**
+ * Whether the Anthropic key is present in the server environment.
+ * Computed per-request and surfaced as a plain boolean so Settings can
+ * show a "key configured / not configured" status WITHOUT the key ever
+ * crossing to the client (spec §9.1, §12).
+ */
+function apiKeyConfigured(): boolean {
+ return !!process.env.ANTHROPIC_API_KEY;
+}
+
+export async function GET() {
+ try {
+ // Settings-class read: admin+ only, matching the table's RLS.
+ const ctx = await requireRole('admin');
+
+ // Try to read the (at most one) config row for this account.
+ const { data: existing, error: selectError } = await ctx.supabase
+ .from('ai_assistant_config')
+ .select(CONFIG_COLUMNS)
+ .eq('account_id', ctx.accountId)
+ .maybeSingle();
+
+ if (selectError) {
+ console.error('[GET /api/ai/config] fetch error:', selectError);
+ return NextResponse.json(
+ { error: 'Failed to load AI configuration' },
+ { status: 500 }
+ );
+ }
+
+ if (existing) {
+ return NextResponse.json({
+ config: existing as AiAssistantConfig,
+ apiKeyConfigured: apiKeyConfigured(),
+ });
+ }
+
+ // No row yet — the account has never opened this tab. Seed a
+ // default row so the UI always has something to bind to. Every
+ // column has a DB default (migration 027), so we only supply
+ // `account_id`; Postgres fills the seeded prompt, keywords,
+ // model, and cap. RLS `ai_assistant_config_insert` permits this
+ // because `requireRole('admin')` already proved the caller is
+ // admin+ for this account.
+ const { data: seeded, error: insertError } = await ctx.supabase
+ .from('ai_assistant_config')
+ .insert({ account_id: ctx.accountId })
+ .select(CONFIG_COLUMNS)
+ .single();
+
+ if (insertError || !seeded) {
+ console.error('[GET /api/ai/config] seed error:', insertError);
+ return NextResponse.json(
+ { error: 'Failed to initialize AI configuration' },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json({
+ config: seeded as AiAssistantConfig,
+ apiKeyConfigured: apiKeyConfigured(),
+ });
+ } catch (err) {
+ return toErrorResponse(err);
+ }
+}
+
+export async function PUT(request: Request) {
+ try {
+ const ctx = await requireRole('admin');
+
+ // Per-admin limit on settings mutations — bounds accidental abuse
+ // (a loop) and a compromised session, with its own bucket so it
+ // doesn't starve other admin endpoints.
+ const limit = checkRateLimit(
+ `admin:aiConfigUpdate:${ctx.userId}`,
+ RATE_LIMITS.adminAction
+ );
+ if (!limit.success) return rateLimitResponse(limit);
+
+ const body = (await request.json().catch(() => null)) as {
+ enabled?: unknown;
+ system_prompt?: unknown;
+ handoff_message?: unknown;
+ escalation_keywords?: unknown;
+ business_name?: unknown;
+ logo_url?: unknown;
+ model?: unknown;
+ daily_reply_cap?: unknown;
+ } | null;
+
+ if (!body || typeof body !== 'object') {
+ return NextResponse.json(
+ { error: 'Request body must be a JSON object' },
+ { status: 400 }
+ );
+ }
+
+ // Build the update from only the editable fields that were
+ // supplied. Validate each; reject the whole request on any bad
+ // field rather than silently dropping it. `id`, `account_id`, and
+ // the timestamps are never writable here.
+ const updates: Record = {};
+
+ if (body.enabled !== undefined) {
+ if (typeof body.enabled !== 'boolean') {
+ return NextResponse.json(
+ { error: "'enabled' must be a boolean" },
+ { status: 400 }
+ );
+ }
+ updates.enabled = body.enabled;
+ }
+
+ if (body.system_prompt !== undefined) {
+ if (typeof body.system_prompt !== 'string') {
+ return NextResponse.json(
+ { error: "'system_prompt' must be a string" },
+ { status: 400 }
+ );
+ }
+ const prompt = body.system_prompt.trim();
+ // NOT NULL in the DB — an empty prompt would strip the safety
+ // instructions, so require non-empty.
+ if (prompt.length === 0) {
+ return NextResponse.json(
+ { error: "'system_prompt' cannot be empty" },
+ { status: 400 }
+ );
+ }
+ if (prompt.length > MAX_PROMPT_LEN) {
+ return NextResponse.json(
+ {
+ error: `'system_prompt' must be ${MAX_PROMPT_LEN} characters or fewer`,
+ },
+ { status: 400 }
+ );
+ }
+ updates.system_prompt = prompt;
+ }
+
+ if (body.handoff_message !== undefined) {
+ // Nullable column — null/empty means "send nothing on escalation".
+ if (body.handoff_message === null) {
+ updates.handoff_message = null;
+ } else if (typeof body.handoff_message === 'string') {
+ const msg = body.handoff_message.trim();
+ if (msg.length > MAX_HANDOFF_LEN) {
+ return NextResponse.json(
+ {
+ error: `'handoff_message' must be ${MAX_HANDOFF_LEN} characters or fewer`,
+ },
+ { status: 400 }
+ );
+ }
+ updates.handoff_message = msg.length === 0 ? null : msg;
+ } else {
+ return NextResponse.json(
+ { error: "'handoff_message' must be a string or null" },
+ { status: 400 }
+ );
+ }
+ }
+
+ if (body.escalation_keywords !== undefined) {
+ if (!Array.isArray(body.escalation_keywords)) {
+ return NextResponse.json(
+ { error: "'escalation_keywords' must be an array of strings" },
+ { status: 400 }
+ );
+ }
+ if (body.escalation_keywords.length > MAX_KEYWORDS) {
+ return NextResponse.json(
+ { error: `At most ${MAX_KEYWORDS} escalation keywords are allowed` },
+ { status: 400 }
+ );
+ }
+ const keywords: string[] = [];
+ for (const raw of body.escalation_keywords) {
+ if (typeof raw !== 'string') {
+ return NextResponse.json(
+ { error: "'escalation_keywords' must be an array of strings" },
+ { status: 400 }
+ );
+ }
+ const kw = raw.trim().toLowerCase();
+ if (kw.length === 0) continue; // drop blanks
+ if (kw.length > MAX_KEYWORD_LEN) {
+ return NextResponse.json(
+ {
+ error: `Each escalation keyword must be ${MAX_KEYWORD_LEN} characters or fewer`,
+ },
+ { status: 400 }
+ );
+ }
+ if (!keywords.includes(kw)) keywords.push(kw); // de-dupe
+ }
+ updates.escalation_keywords = keywords;
+ }
+
+ if (body.business_name !== undefined) {
+ if (body.business_name === null) {
+ updates.business_name = null;
+ } else if (typeof body.business_name === 'string') {
+ const name = body.business_name.trim();
+ if (name.length > MAX_BUSINESS_NAME_LEN) {
+ return NextResponse.json(
+ {
+ error: `'business_name' must be ${MAX_BUSINESS_NAME_LEN} characters or fewer`,
+ },
+ { status: 400 }
+ );
+ }
+ updates.business_name = name.length === 0 ? null : name;
+ } else {
+ return NextResponse.json(
+ { error: "'business_name' must be a string or null" },
+ { status: 400 }
+ );
+ }
+ }
+
+ if (body.logo_url !== undefined) {
+ if (body.logo_url === null) {
+ updates.logo_url = null;
+ } else if (typeof body.logo_url === 'string') {
+ const url = body.logo_url.trim();
+ if (url.length > MAX_LOGO_URL_LEN) {
+ return NextResponse.json(
+ {
+ error: `'logo_url' must be ${MAX_LOGO_URL_LEN} characters or fewer`,
+ },
+ { status: 400 }
+ );
+ }
+ updates.logo_url = url.length === 0 ? null : url;
+ } else {
+ return NextResponse.json(
+ { error: "'logo_url' must be a string or null" },
+ { status: 400 }
+ );
+ }
+ }
+
+ if (body.model !== undefined) {
+ if (typeof body.model !== 'string') {
+ return NextResponse.json(
+ { error: "'model' must be a string" },
+ { status: 400 }
+ );
+ }
+ const model = body.model.trim();
+ // NOT NULL with a DB default — never allow it to be blanked.
+ if (model.length === 0) {
+ return NextResponse.json(
+ { error: "'model' cannot be empty" },
+ { status: 400 }
+ );
+ }
+ if (model.length > MAX_MODEL_LEN) {
+ return NextResponse.json(
+ { error: `'model' must be ${MAX_MODEL_LEN} characters or fewer` },
+ { status: 400 }
+ );
+ }
+ updates.model = model;
+ }
+
+ if (body.daily_reply_cap !== undefined) {
+ const cap = body.daily_reply_cap;
+ if (
+ typeof cap !== 'number' ||
+ !Number.isInteger(cap) ||
+ cap < 1 ||
+ cap > MAX_DAILY_REPLY_CAP
+ ) {
+ return NextResponse.json(
+ {
+ error: `'daily_reply_cap' must be an integer between 1 and ${MAX_DAILY_REPLY_CAP}`,
+ },
+ { status: 400 }
+ );
+ }
+ updates.daily_reply_cap = cap;
+ }
+
+ if (Object.keys(updates).length === 0) {
+ return NextResponse.json(
+ { error: 'No editable fields supplied' },
+ { status: 400 }
+ );
+ }
+
+ // Update the account's row. RLS `ai_assistant_config_update` allows
+ // this because `requireRole('admin')` already proved the caller is
+ // admin+ for this account; `.eq('account_id')` keeps it scoped even
+ // so. If the row doesn't exist yet (the tab was never opened), seed
+ // it from the supplied values merged over the column defaults, then
+ // re-run as an insert so PUT is usable without a prior GET.
+ const { data: updated, error: updateError } = await ctx.supabase
+ .from('ai_assistant_config')
+ .update(updates)
+ .eq('account_id', ctx.accountId)
+ .select(CONFIG_COLUMNS)
+ .maybeSingle();
+
+ if (updateError) {
+ console.error('[PUT /api/ai/config] update error:', updateError);
+ return NextResponse.json(
+ { error: 'Failed to update AI configuration' },
+ { status: 500 }
+ );
+ }
+
+ if (updated) {
+ return NextResponse.json({
+ config: updated as AiAssistantConfig,
+ apiKeyConfigured: apiKeyConfigured(),
+ });
+ }
+
+ // No existing row matched — seed one with the supplied values laid
+ // over the DB column defaults.
+ const { data: inserted, error: insertError } = await ctx.supabase
+ .from('ai_assistant_config')
+ .insert({ account_id: ctx.accountId, ...updates })
+ .select(CONFIG_COLUMNS)
+ .single();
+
+ if (insertError || !inserted) {
+ console.error('[PUT /api/ai/config] seed error:', insertError);
+ return NextResponse.json(
+ { error: 'Failed to update AI configuration' },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json({
+ config: inserted as AiAssistantConfig,
+ apiKeyConfigured: apiKeyConfigured(),
+ });
+ } catch (err) {
+ return toErrorResponse(err);
+ }
+}
diff --git a/src/app/api/ai/knowledge/[id]/route.ts b/src/app/api/ai/knowledge/[id]/route.ts
new file mode 100644
index 0000000000..e318e61cde
--- /dev/null
+++ b/src/app/api/ai/knowledge/[id]/route.ts
@@ -0,0 +1,229 @@
+// ============================================================
+// /api/ai/knowledge/[id]
+//
+// PATCH — update a single knowledge_base_entry (title, content,
+// and/or enabled). Recomputes `token_estimate` whenever
+// `content` changes.
+// DELETE — remove a single entry.
+//
+// These are the *dashboard* endpoints for editing one KB page
+// (spec §10), so they authenticate the normal way (cookie session)
+// and go through the RLS client. Both methods are admin+ only: the
+// KB is account-settings-class data (spec §4.2 RLS: all ops for
+// `is_account_member(account_id, 'admin')`), so we enforce the same
+// admin floor on the wire that the table's RLS policies enforce in
+// the database — `requireRole('admin')` plus the `canEditSettings`
+// capability predicate (defence in depth alongside RLS).
+//
+// Tenancy: every query is scoped by BOTH `id` and `account_id`, so an
+// admin can never touch another account's entry by guessing a UUID.
+// RLS already enforces this; the explicit `.eq('account_id', …)`
+// filter is belt-and-braces and makes the "0 rows → 404" path precise.
+//
+// `token_estimate` is recomputed server-side from the new content via
+// the shared chars/4 heuristic (`estimateTokens`, spec §4.2 / §9), so
+// the Settings size meter stays consistent regardless of caller and
+// matches the value the collection route's POST writes.
+// ============================================================
+
+import { NextResponse } from 'next/server';
+
+import {
+ ForbiddenError,
+ requireRole,
+ toErrorResponse,
+} from '@/lib/auth/account';
+import { canEditSettings } from '@/lib/auth/roles';
+import { estimateTokens } from '@/lib/ai/knowledge-base';
+import {
+ checkRateLimit,
+ rateLimitResponse,
+ RATE_LIMITS,
+} from '@/lib/rate-limit';
+
+const MAX_TITLE_LEN = 200;
+// Hard ceiling on a single entry's content. A KB page far longer
+// than this is almost certainly a misuse (paste a whole site) and
+// would blow the prompt budget on its own — reject early rather than
+// silently truncate. Mirrors the collection route's POST.
+const MAX_CONTENT_LEN = 200_000;
+
+// Columns safe to expose. Mirrors the `KnowledgeBaseEntry` shape and
+// the collection route's `SAFE_COLUMNS`.
+const SAFE_COLUMNS =
+ 'id, account_id, title, content, source_type, source_filename, enabled, token_estimate, created_by_user_id, created_at, updated_at';
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const ctx = await requireRole('admin');
+ // Defence in depth: the capability predicate is the single source
+ // of truth for "can edit account settings" (the KB is settings-
+ // class), alongside the role floor above and the table RLS policy.
+ if (!canEditSettings(ctx.role)) {
+ throw new ForbiddenError('This action requires the admin role or higher');
+ }
+
+ const limit = checkRateLimit(
+ `admin:knowledgeUpdate:${ctx.userId}`,
+ RATE_LIMITS.adminAction
+ );
+ if (!limit.success) return rateLimitResponse(limit);
+
+ const { id } = await params;
+
+ const body = (await request.json().catch(() => null)) as {
+ title?: unknown;
+ content?: unknown;
+ enabled?: unknown;
+ } | null;
+ if (!body) {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
+ }
+
+ // Build the patch from only the fields the caller actually sent, so
+ // a partial update (e.g. just toggling `enabled`) leaves the rest
+ // untouched. Each field is validated as it's added.
+ const update: Record = {};
+
+ if ('title' in body) {
+ const rawTitle =
+ typeof body.title === 'string' ? body.title.trim() : '';
+ if (!rawTitle) {
+ return NextResponse.json(
+ { error: "'title' must be a non-empty string" },
+ { status: 400 }
+ );
+ }
+ if (rawTitle.length > MAX_TITLE_LEN) {
+ return NextResponse.json(
+ { error: `Title must be ${MAX_TITLE_LEN} characters or fewer` },
+ { status: 400 }
+ );
+ }
+ update.title = rawTitle;
+ }
+
+ if ('content' in body) {
+ const rawContent =
+ typeof body.content === 'string' ? body.content.trim() : '';
+ if (!rawContent) {
+ return NextResponse.json(
+ { error: "'content' must be a non-empty string" },
+ { status: 400 }
+ );
+ }
+ if (rawContent.length > MAX_CONTENT_LEN) {
+ return NextResponse.json(
+ { error: `Content must be ${MAX_CONTENT_LEN} characters or fewer` },
+ { status: 400 }
+ );
+ }
+ update.content = rawContent;
+ // Recompute the cached estimate whenever content changes so the
+ // size meter never drifts from the stored text (spec §4.2 / §9).
+ update.token_estimate = estimateTokens(rawContent);
+ }
+
+ if ('enabled' in body) {
+ if (typeof body.enabled !== 'boolean') {
+ return NextResponse.json(
+ { error: "'enabled' must be a boolean" },
+ { status: 400 }
+ );
+ }
+ update.enabled = body.enabled;
+ }
+
+ if (Object.keys(update).length === 0) {
+ return NextResponse.json(
+ {
+ error:
+ "Nothing to update — provide at least one of 'title', 'content', or 'enabled'",
+ },
+ { status: 400 }
+ );
+ }
+
+ // Scope the update by account_id as well as id so an admin can never
+ // edit another account's entry by guessing a UUID. (RLS already
+ // enforces this; the explicit filter makes the "0 rows → 404" path
+ // precise.)
+ const { data, error } = await ctx.supabase
+ .from('knowledge_base_entries')
+ .update(update)
+ .eq('id', id)
+ .eq('account_id', ctx.accountId)
+ .select(SAFE_COLUMNS)
+ .maybeSingle();
+
+ if (error) {
+ console.error('[PATCH /api/ai/knowledge/[id]] update error:', error);
+ return NextResponse.json(
+ { error: 'Failed to update knowledge base entry' },
+ { status: 500 }
+ );
+ }
+ if (!data) {
+ return NextResponse.json(
+ { error: 'Knowledge base entry not found' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({ entry: data });
+ } catch (err) {
+ return toErrorResponse(err);
+ }
+}
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const ctx = await requireRole('admin');
+ if (!canEditSettings(ctx.role)) {
+ throw new ForbiddenError('This action requires the admin role or higher');
+ }
+
+ const limit = checkRateLimit(
+ `admin:knowledgeDelete:${ctx.userId}`,
+ RATE_LIMITS.adminAction
+ );
+ if (!limit.success) return rateLimitResponse(limit);
+
+ const { id } = await params;
+
+ // Account-scoped delete (id + account_id) for the same tenancy
+ // reason as the PATCH above. `.select('id').maybeSingle()` lets us
+ // tell "deleted" from "no such entry in this account" → 404.
+ const { data, error } = await ctx.supabase
+ .from('knowledge_base_entries')
+ .delete()
+ .eq('id', id)
+ .eq('account_id', ctx.accountId)
+ .select('id')
+ .maybeSingle();
+
+ if (error) {
+ console.error('[DELETE /api/ai/knowledge/[id]] delete error:', error);
+ return NextResponse.json(
+ { error: 'Failed to delete knowledge base entry' },
+ { status: 500 }
+ );
+ }
+ if (!data) {
+ return NextResponse.json(
+ { error: 'Knowledge base entry not found' },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ return toErrorResponse(err);
+ }
+}
diff --git a/src/app/api/ai/knowledge/route.ts b/src/app/api/ai/knowledge/route.ts
new file mode 100644
index 0000000000..67d7b97036
--- /dev/null
+++ b/src/app/api/ai/knowledge/route.ts
@@ -0,0 +1,150 @@
+// ============================================================
+// /api/ai/knowledge
+//
+// GET — list this account's knowledge_base_entries.
+// POST — create a manual entry (title, content).
+//
+// These are the *dashboard* endpoints for managing the AI
+// assistant's knowledge base (spec §10), so they authenticate the
+// normal way (cookie session) and go through the RLS client. Both
+// methods are admin+ only: the KB is account-settings-class data
+// (spec §4.2 RLS: all ops for `is_account_member(account_id,
+// 'admin')`), so we enforce the same admin floor on the wire that
+// the table's RLS policies enforce in the database.
+//
+// `token_estimate` is computed server-side from the content via the
+// shared chars/4 heuristic (`estimateTokens`, spec §4.2 / §9) so the
+// Settings size meter has a consistent value regardless of caller.
+// `source_type` is fixed to 'manual' here; file-sourced entries come
+// in through the dedicated upload route.
+// ============================================================
+
+import { NextResponse } from 'next/server';
+
+import {
+ ForbiddenError,
+ requireRole,
+ toErrorResponse,
+} from '@/lib/auth/account';
+import { canEditSettings } from '@/lib/auth/roles';
+import { estimateTokens } from '@/lib/ai/knowledge-base';
+import {
+ checkRateLimit,
+ rateLimitResponse,
+ RATE_LIMITS,
+} from '@/lib/rate-limit';
+
+const MAX_TITLE_LEN = 200;
+// Hard ceiling on a single entry's content. A KB page far longer
+// than this is almost certainly a misuse (paste a whole site) and
+// would blow the prompt budget on its own — reject early rather than
+// silently truncate.
+const MAX_CONTENT_LEN = 200_000;
+
+// Columns safe to expose. Mirrors the `KnowledgeBaseEntry` shape.
+const SAFE_COLUMNS =
+ 'id, account_id, title, content, source_type, source_filename, enabled, token_estimate, created_by_user_id, created_at, updated_at';
+
+export async function GET() {
+ try {
+ const ctx = await requireRole('admin');
+ // Defence in depth: the capability predicate is the single source
+ // of truth for "can edit account settings" (the KB is settings-
+ // class), alongside the role floor above and the table RLS policy.
+ if (!canEditSettings(ctx.role)) {
+ throw new ForbiddenError('This action requires the admin role or higher');
+ }
+
+ const { data, error } = await ctx.supabase
+ .from('knowledge_base_entries')
+ .select(SAFE_COLUMNS)
+ .eq('account_id', ctx.accountId)
+ .order('created_at', { ascending: false });
+
+ if (error) {
+ console.error('[GET /api/ai/knowledge] fetch error:', error);
+ return NextResponse.json(
+ { error: 'Failed to load knowledge base entries' },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json({ entries: data ?? [] });
+ } catch (err) {
+ return toErrorResponse(err);
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const ctx = await requireRole('admin');
+ if (!canEditSettings(ctx.role)) {
+ throw new ForbiddenError('This action requires the admin role or higher');
+ }
+
+ const limit = checkRateLimit(
+ `admin:knowledgeCreate:${ctx.userId}`,
+ RATE_LIMITS.adminAction
+ );
+ if (!limit.success) return rateLimitResponse(limit);
+
+ const body = (await request.json().catch(() => null)) as {
+ title?: unknown;
+ content?: unknown;
+ } | null;
+
+ const rawTitle = typeof body?.title === 'string' ? body.title.trim() : '';
+ if (!rawTitle) {
+ return NextResponse.json(
+ { error: "'title' is required" },
+ { status: 400 }
+ );
+ }
+ if (rawTitle.length > MAX_TITLE_LEN) {
+ return NextResponse.json(
+ { error: `Title must be ${MAX_TITLE_LEN} characters or fewer` },
+ { status: 400 }
+ );
+ }
+
+ const rawContent =
+ typeof body?.content === 'string' ? body.content.trim() : '';
+ if (!rawContent) {
+ return NextResponse.json(
+ { error: "'content' is required" },
+ { status: 400 }
+ );
+ }
+ if (rawContent.length > MAX_CONTENT_LEN) {
+ return NextResponse.json(
+ { error: `Content must be ${MAX_CONTENT_LEN} characters or fewer` },
+ { status: 400 }
+ );
+ }
+
+ const { data, error } = await ctx.supabase
+ .from('knowledge_base_entries')
+ .insert({
+ account_id: ctx.accountId,
+ title: rawTitle,
+ content: rawContent,
+ source_type: 'manual',
+ token_estimate: estimateTokens(rawContent),
+ created_by_user_id: ctx.userId,
+ })
+ .select(SAFE_COLUMNS)
+ .single();
+
+ if (error || !data) {
+ console.error('[POST /api/ai/knowledge] insert error:', error);
+ return NextResponse.json(
+ { error: 'Failed to create knowledge base entry' },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json({ entry: data }, { status: 201 });
+ } catch (err) {
+ return toErrorResponse(err);
+ }
+}
diff --git a/src/app/api/ai/knowledge/upload/route.ts b/src/app/api/ai/knowledge/upload/route.ts
new file mode 100644
index 0000000000..b7823f15d8
--- /dev/null
+++ b/src/app/api/ai/knowledge/upload/route.ts
@@ -0,0 +1,219 @@
+// ============================================================
+// /api/ai/knowledge/upload
+//
+// POST — accept an uploaded file, extract its text, and create a
+// `source_type='file'` knowledge_base_entries row.
+//
+// This is the file-import counterpart of the manual-create endpoint
+// (`POST /api/ai/knowledge`, spec §9.4 / §10). It authenticates the
+// dashboard way (cookie session → RLS client) and is admin+ only:
+// the KB is account-settings-class data (spec §4.2 RLS: all ops for
+// `is_account_member(account_id, 'admin')`), so we enforce the same
+// admin floor on the wire that the table's RLS policies enforce in
+// the database — the exact guard the sibling `route.ts` uses.
+//
+// Supported inputs (spec §9.4):
+// .txt / .md — read directly as UTF-8 text.
+// .pdf — text extracted server-side with `unpdf` (a serverless
+// PDF.js build). On any extraction failure we return a
+// clear 422 telling the user to paste the text manually,
+// rather than persisting an empty/garbage entry.
+//
+// `token_estimate` is computed from the extracted text via the shared
+// chars/4 heuristic (`estimateTokens`, spec §4.2 / §9), identical to
+// the manual route, so the Settings size meter stays consistent
+// regardless of how an entry was created.
+//
+// Runtime note: PDF extraction runs Mozilla's PDF.js, which needs the
+// Node.js runtime — pin it explicitly so this route never gets pushed
+// onto the Edge runtime where those APIs are unavailable.
+// ============================================================
+
+import { NextResponse } from 'next/server';
+
+import {
+ ForbiddenError,
+ requireRole,
+ toErrorResponse,
+} from '@/lib/auth/account';
+import { canEditSettings } from '@/lib/auth/roles';
+import { estimateTokens } from '@/lib/ai/knowledge-base';
+import {
+ checkRateLimit,
+ rateLimitResponse,
+ RATE_LIMITS,
+} from '@/lib/rate-limit';
+
+export const runtime = 'nodejs';
+
+const MAX_TITLE_LEN = 200;
+// Hard ceiling on a single entry's content, mirroring the manual
+// create route. Extracted text longer than this is almost certainly a
+// misuse (a whole manual / site dump) and would blow the prompt budget
+// on its own — reject rather than silently truncate.
+const MAX_CONTENT_LEN = 200_000;
+
+// Cap the raw upload so a single request can't pull a huge file into
+// memory before we even look at it. 10 MB comfortably covers a text
+// KB page or a reasonable PDF while bounding the PDF.js workload.
+const MAX_FILE_BYTES = 10 * 1024 * 1024;
+
+// Columns safe to expose. Mirrors the `KnowledgeBaseEntry` shape and
+// the SAFE_COLUMNS list in the sibling `route.ts`.
+const SAFE_COLUMNS =
+ 'id, account_id, title, content, source_type, source_filename, enabled, token_estimate, created_by_user_id, created_at, updated_at';
+
+/** Classify an upload by its filename extension. */
+type FileKind = 'text' | 'pdf' | 'unsupported';
+
+function classify(filename: string): FileKind {
+ const lower = filename.toLowerCase();
+ if (lower.endsWith('.txt') || lower.endsWith('.md')) return 'text';
+ if (lower.endsWith('.pdf')) return 'pdf';
+ return 'unsupported';
+}
+
+/**
+ * Extract text from a PDF buffer using `unpdf` (serverless PDF.js).
+ * Returns the merged text, or `null` on any failure / empty result so
+ * the caller can surface a clean 422 rather than persisting nothing.
+ */
+async function extractPdfText(bytes: Uint8Array): Promise {
+ try {
+ const { extractText, getDocumentProxy } = await import('unpdf');
+ const pdf = await getDocumentProxy(bytes);
+ const { text } = await extractText(pdf, { mergePages: true });
+ const trimmed = text.trim();
+ return trimmed.length > 0 ? trimmed : null;
+ } catch (err) {
+ console.error('[POST /api/ai/knowledge/upload] PDF extract error:', err);
+ return null;
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const ctx = await requireRole('admin');
+ // Defence in depth: the capability predicate is the single source
+ // of truth for "can edit account settings" (the KB is settings-
+ // class), alongside the role floor above and the table RLS policy.
+ if (!canEditSettings(ctx.role)) {
+ throw new ForbiddenError('This action requires the admin role or higher');
+ }
+
+ const limit = checkRateLimit(
+ `admin:knowledgeUpload:${ctx.userId}`,
+ RATE_LIMITS.adminAction
+ );
+ if (!limit.success) return rateLimitResponse(limit);
+
+ const form = await request.formData().catch(() => null);
+ const file = form?.get('file');
+ if (!(file instanceof File)) {
+ return NextResponse.json(
+ { error: "A 'file' field is required" },
+ { status: 400 }
+ );
+ }
+
+ const filename = file.name?.trim() || 'upload';
+ const kind = classify(filename);
+ if (kind === 'unsupported') {
+ return NextResponse.json(
+ { error: 'Unsupported file type. Upload a .txt, .md, or .pdf file.' },
+ { status: 400 }
+ );
+ }
+
+ if (file.size > MAX_FILE_BYTES) {
+ return NextResponse.json(
+ {
+ error: `File is too large. Maximum size is ${Math.floor(
+ MAX_FILE_BYTES / (1024 * 1024)
+ )} MB.`,
+ },
+ { status: 400 }
+ );
+ }
+
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ if (bytes.byteLength === 0) {
+ return NextResponse.json(
+ { error: 'The uploaded file is empty.' },
+ { status: 400 }
+ );
+ }
+
+ let content: string;
+ if (kind === 'pdf') {
+ const extracted = await extractPdfText(bytes);
+ if (extracted === null) {
+ // Fail safe: don't persist an empty/garbage entry. Tell the
+ // user exactly what to do instead (spec §1 fail-safe bias).
+ return NextResponse.json(
+ {
+ error:
+ "Couldn't extract text from this PDF. It may be scanned, " +
+ 'image-only, or encrypted. Copy the text and paste it in ' +
+ 'manually instead.',
+ },
+ { status: 422 }
+ );
+ }
+ content = extracted;
+ } else {
+ content = new TextDecoder('utf-8').decode(bytes).trim();
+ }
+
+ if (!content) {
+ return NextResponse.json(
+ {
+ error:
+ "Couldn't read any text from this file. Copy the text and " +
+ 'paste it in manually instead.',
+ },
+ { status: 422 }
+ );
+ }
+ if (content.length > MAX_CONTENT_LEN) {
+ return NextResponse.json(
+ {
+ error: `Extracted text is too long (${content.length} characters). Maximum is ${MAX_CONTENT_LEN}. Split it into smaller entries.`,
+ },
+ { status: 400 }
+ );
+ }
+
+ // Derive a title from the filename (strip the extension), clamped
+ // to the same ceiling the manual route enforces. Falls back to the
+ // raw filename if stripping leaves nothing.
+ const baseTitle = filename.replace(/\.[^.]+$/, '').trim() || filename;
+ const title = baseTitle.slice(0, MAX_TITLE_LEN);
+
+ const { data, error } = await ctx.supabase
+ .from('knowledge_base_entries')
+ .insert({
+ account_id: ctx.accountId,
+ title,
+ content,
+ source_type: 'file',
+ source_filename: filename.slice(0, MAX_TITLE_LEN),
+ token_estimate: estimateTokens(content),
+ created_by_user_id: ctx.userId,
+ })
+ .select(SAFE_COLUMNS)
+ .single();
+
+ if (error || !data) {
+ console.error('[POST /api/ai/knowledge/upload] insert error:', error);
+ return NextResponse.json(
+ { error: 'Failed to create knowledge base entry' },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json({ entry: data }, { status: 201 });
+ } catch (err) {
+ return toErrorResponse(err);
+ }
+}
From 95d42f41a6778052bcb2f777428f5e56e4a0a77d Mon Sep 17 00:00:00 2001
From: Martinxmaina
Date: Fri, 26 Jun 2026 00:05:32 +0300
Subject: [PATCH 4/8] feat(ai): Settings AI Assistant tab + knowledge base
manager + inbox hand-off UX
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
UI layer for the assistant (spec §8, §9):
- Settings → "AI Assistant": enable toggle, system-prompt editor, handoff
message, escalation-keyword chips, persona (business name + logo upload
via the existing account storage helper), model dropdown, daily reply
cap; admin+ gated (RequireRole). Shows an "API key not configured"
notice from the server-reported boolean.
- KnowledgeBaseManager: CRUD + enable toggle + file import (.txt/.md/PDF),
with a token-size meter vs a 150k soft budget (the RAG-upgrade signal).
- Inbox: "🙋 Needs human" badge on escalated conversations, "AI" tag on
bot-sent messages, and Take over / Hand back to AI header controls
(reuse the existing conversations.update path; optimistic + realtime).
typecheck + lint(0 errors) + test(526) + build all green.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/app/(dashboard)/inbox/page.tsx | 18 +
src/app/(dashboard)/settings/page.tsx | 2 +
src/components/inbox/conversation-list.tsx | 10 +
src/components/inbox/message-bubble.tsx | 8 +
src/components/inbox/message-thread.tsx | 97 +++
.../settings/ai-assistant-settings.tsx | 675 ++++++++++++++++++
.../settings/knowledge-base-manager.tsx | 620 ++++++++++++++++
src/components/settings/settings-overview.tsx | 5 +
src/components/settings/settings-sections.ts | 3 +
9 files changed, 1438 insertions(+)
create mode 100644 src/components/settings/ai-assistant-settings.tsx
create mode 100644 src/components/settings/knowledge-base-manager.tsx
diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx
index 7ec389549b..38eea74d17 100644
--- a/src/app/(dashboard)/inbox/page.tsx
+++ b/src/app/(dashboard)/inbox/page.tsx
@@ -537,6 +537,23 @@ export default function InboxPage() {
[activeConversation]
);
+ // Reflect a Take over / Hand back to AI toggle. Mirrors the status /
+ // assign handlers: patch the list row + active conversation optimistically
+ // so the header control and the "Needs human" badge update instantly; the
+ // realtime conversations.UPDATE the Supabase write emits converges the
+ // same fields as a no-op.
+ const handleAiHandlingChange = useCallback(
+ (conversationId: string, updates: Partial) => {
+ setConversations((prev) =>
+ prev.map((c) => (c.id === conversationId ? { ...c, ...updates } : c))
+ );
+ if (activeConversation?.id === conversationId) {
+ setActiveConversation((prev) => (prev ? { ...prev, ...updates } : prev));
+ }
+ },
+ [activeConversation]
+ );
+
// On mobile (,
appearance: ,
whatsapp: ,
+ ai: ,
templates: ,
fields: ,
deals: ,
diff --git a/src/components/inbox/conversation-list.tsx b/src/components/inbox/conversation-list.tsx
index 69f51dd6fe..8068d8458d 100644
--- a/src/components/inbox/conversation-list.tsx
+++ b/src/components/inbox/conversation-list.tsx
@@ -283,6 +283,16 @@ function ConversationItem({
{conversation.last_message_text || "No messages yet"}
+ {/* AI handed this thread off to a human — surfaces live via the
+ existing conversations realtime UPDATE (page bumps the row's
+ ai_* fields into state). Tied to status==='pending' so a
+ taken-over/handed-back thread (escalation cleared) drops it. */}
+ {conversation.ai_escalated_at &&
+ conversation.status === "pending" && (
+
+ 🙋 Needs human
+
+ )}
{conversation.unread_count > 0 && (
{conversation.unread_count}
diff --git a/src/components/inbox/message-bubble.tsx b/src/components/inbox/message-bubble.tsx
index 510f25c4cd..d3b2f2ffed 100644
--- a/src/components/inbox/message-bubble.tsx
+++ b/src/components/inbox/message-bubble.tsx
@@ -249,6 +249,7 @@ export function MessageBubble({
onToggleReaction,
}: MessageBubbleProps) {
const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
+ const isBot = message.sender_type === "bot";
const time = format(new Date(message.created_at), "HH:mm");
// Row alignment + width cap are owned by so its hover
@@ -282,6 +283,13 @@ export function MessageBubble({
isAgent ? "justify-end" : "justify-start",
)}
>
+ {/* Mark assistant-sent replies so agents can tell at a glance
+ which messages the AI sent (sender_type==='bot'). */}
+ {isBot && (
+
+ AI
+
+ )}
void;
+ /**
+ * Reflect an AI hand-off toggle (Take over / Hand back to AI). Patches
+ * the affected conversation's `ai_*` fields in the page's state so the
+ * header + list badge update immediately; the realtime UPDATE that the
+ * Supabase write emits converges the same fields as a no-op.
+ */
+ onAiHandlingChange: (
+ conversationId: string,
+ updates: Partial,
+ ) => void;
/**
* On mobile, the thread is shown full-screen with the conversation list
* hidden. This callback lets the page deselect the active conversation
@@ -159,6 +171,7 @@ export function MessageThread({
onUpdateMessage,
onStatusChange,
onAssignChange,
+ onAiHandlingChange,
onBack,
resyncToken = 0,
onRefresh,
@@ -778,6 +791,55 @@ export function MessageThread({
[conversation, onAssignChange],
);
+ // Take over (human owns the thread; AI goes silent) / Hand back to AI
+ // (re-arm the bot and clear the escalation). Reuses the same
+ // conversations.update() path the status/assign controls use; the
+ // backend send route already flips ai_handling=false on a manual reply,
+ // so this just covers the explicit toggle. Issue: spec §8.
+ const handleAiHandlingChange = useCallback(
+ async (handling: boolean) => {
+ if (!conversation) return;
+
+ const updates: Partial = handling
+ ? {
+ // Hand back: re-arm the bot and clear the escalation markers so
+ // the "Needs human" badge drops and the thread is bot-eligible.
+ ai_handling: true,
+ ai_escalated_at: undefined,
+ ai_escalation_reason: undefined,
+ }
+ : { ai_handling: false };
+
+ const supabase = createClient();
+ const { error } = await supabase
+ .from("conversations")
+ // null clears the columns in the DB; the optimistic `updates`
+ // patch above uses `undefined` to drop them from the client row.
+ .update(
+ handling
+ ? {
+ ai_handling: true,
+ ai_escalated_at: null,
+ ai_escalation_reason: null,
+ }
+ : { ai_handling: false },
+ )
+ .eq("id", conversation.id);
+
+ if (error) {
+ console.error("Failed to update AI handling:", error);
+ toast.error(
+ handling ? "Failed to hand back to AI" : "Failed to take over",
+ );
+ return;
+ }
+
+ onAiHandlingChange(conversation.id, updates);
+ toast.success(handling ? "Handed back to AI" : "You've taken over");
+ },
+ [conversation, onAiHandlingChange],
+ );
+
// Empty state — same WhatsApp-style doodle background as the active
// thread below, so swapping between empty/selected doesn't change the
// pattern under the user's eye.
@@ -808,6 +870,14 @@ export function MessageThread({
? (currentAssignee?.full_name ?? "Assigned")
: "Assign";
+ // AI hand-off state for the header controls. `ai_handling` defaults to
+ // true at the DB level, so an undefined value reads as "AI is driving".
+ // The bot is considered active only while it's handling AND hasn't
+ // escalated; once escalated or taken over we offer "Hand back to AI".
+ const aiHandling = conversation.ai_handling !== false;
+ const aiEscalated = !!conversation.ai_escalated_at;
+ const aiActive = aiHandling && !aiEscalated;
+
return (
// `min-w-0` is load-bearing: the page already puts min-w-0 on the
// thread's flex *wrapper* (issue #165), but this root keeps the
@@ -905,6 +975,33 @@ export function MessageThread({
)}
+ {/* AI hand-off control — matches the other header pills. While
+ the AI is driving (handling && not escalated) we offer "Take
+ over"; once escalated or a human has taken over we offer
+ "Hand back to AI". Sending a manual reply also takes over
+ (handled in the backend send route). Spec §8. */}
+ {aiActive ? (
+
+ ) : (
+
+ )}
+
{/* Status dropdown */}
) rendered at the bottom.
+//
+// Loads GET /api/ai/config (which lazily seeds a default row, so the
+// form always binds to something) and saves the editable fields via
+// PUT. The server reports `apiKeyConfigured` as a plain boolean — the
+// Anthropic key itself never reaches the client (spec §9.1 / §12) — so
+// we surface a "key not configured" notice when it's false: the
+// assistant can't reply without it.
+//
+// Admin+ only. The settings page slots this behind the rail item; we
+// also guard locally with so a non-admin who deep-links
+// gets a clean message instead of failing API calls. Mirrors the save/
+// load/toast conventions of whatsapp-config.tsx and the logo upload of
+// profile-form.tsx (via the shared account-scoped storage helper).
+// ============================================================
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { toast } from 'sonner';
+import {
+ Loader2,
+ Upload,
+ Trash2,
+ X,
+ Plus,
+ AlertTriangle,
+ Bot,
+ ShieldAlert,
+} from 'lucide-react';
+
+import { uploadAccountMedia } from '@/lib/storage/upload-media';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Switch } from '@/components/ui/switch';
+import { Badge } from '@/components/ui/badge';
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from '@/components/ui/avatar';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from '@/components/ui/card';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { RequireRole } from '@/components/auth/require-role';
+import { SettingsPanelHead } from './settings-panel-head';
+import { KnowledgeBaseManager } from './knowledge-base-manager';
+import type { AiAssistantConfig } from '@/types';
+
+// The two models offered in the dropdown (spec §9.5 / §2). The stored
+// `model` string may be something else (set via the API) — we render it
+// as a fallback option so a custom value never silently disappears.
+const MODEL_OPTIONS: { value: string; label: string }[] = [
+ { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 — best quality' },
+ { value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5 — cheaper & faster' },
+];
+
+// Logo upload: reuse the account-scoped storage helper + bucket the
+// inbox/templates already use. The bucket accepts PNG/JPEG/WebP (see
+// migration 023), so restrict to those — GIF is not accepted there.
+const LOGO_BUCKET = 'chat-media';
+const LOGO_MAX_BYTES = 2 * 1024 * 1024;
+const LOGO_ALLOWED_MIME = new Set([
+ 'image/png',
+ 'image/jpeg',
+ 'image/webp',
+]);
+
+// Bounds mirrored from the PUT /api/ai/config validator so the UI
+// stops obvious over-length input before a round-trip.
+const MAX_KEYWORDS = 100;
+const MAX_KEYWORD_LEN = 80;
+const MAX_DAILY_REPLY_CAP = 100_000;
+
+export function AiAssistantSettings() {
+ return (
+
+
+
+
+
+
+ Only admins can configure the AI assistant.
+
+
+ Ask an account admin if you need changes made here.
+
+
+
+
+ }
+ >
+
+
+ );
+}
+
+function AiAssistantSettingsInner() {
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [apiKeyConfigured, setApiKeyConfigured] = useState(true);
+
+ // The persisted config (last-saved snapshot), used to compute the
+ // dirty state. `null` until the first load resolves.
+ const [config, setConfig] = useState(null);
+
+ // Editable form fields.
+ const [enabled, setEnabled] = useState(false);
+ const [systemPrompt, setSystemPrompt] = useState('');
+ const [handoffMessage, setHandoffMessage] = useState('');
+ const [keywords, setKeywords] = useState([]);
+ const [keywordDraft, setKeywordDraft] = useState('');
+ const [businessName, setBusinessName] = useState('');
+ const [model, setModel] = useState('claude-sonnet-4-6');
+ const [dailyReplyCap, setDailyReplyCap] = useState('500');
+
+ // Logo: existing URL + a staged (unsaved) replacement/removal.
+ const [logoUrl, setLogoUrl] = useState(null);
+ const [uploadingLogo, setUploadingLogo] = useState(false);
+ const logoInputRef = useRef(null);
+
+ const seedFromConfig = useCallback((c: AiAssistantConfig) => {
+ setConfig(c);
+ setEnabled(c.enabled);
+ setSystemPrompt(c.system_prompt ?? '');
+ setHandoffMessage(c.handoff_message ?? '');
+ setKeywords(c.escalation_keywords ?? []);
+ setBusinessName(c.business_name ?? '');
+ setModel(c.model ?? 'claude-sonnet-4-6');
+ setDailyReplyCap(String(c.daily_reply_cap ?? 500));
+ setLogoUrl(c.logo_url ?? null);
+ }, []);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await fetch('/api/ai/config', { cache: 'no-store' });
+ if (!res.ok) {
+ const payload = await res.json().catch(() => ({}));
+ toast.error(payload.error || 'Failed to load AI configuration');
+ return;
+ }
+ const data = (await res.json()) as {
+ config: AiAssistantConfig;
+ apiKeyConfigured: boolean;
+ };
+ seedFromConfig(data.config);
+ setApiKeyConfigured(data.apiKeyConfigured);
+ } catch (err) {
+ console.error('[AiAssistantSettings] load error:', err);
+ toast.error('Could not reach the server');
+ } finally {
+ setLoading(false);
+ }
+ }, [seedFromConfig]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ function addKeyword(raw: string) {
+ const kw = raw.trim().toLowerCase();
+ if (!kw) return;
+ if (kw.length > MAX_KEYWORD_LEN) {
+ toast.error(`Keywords must be ${MAX_KEYWORD_LEN} characters or fewer`);
+ return;
+ }
+ if (keywords.includes(kw)) {
+ setKeywordDraft('');
+ return;
+ }
+ if (keywords.length >= MAX_KEYWORDS) {
+ toast.error(`At most ${MAX_KEYWORDS} keywords`);
+ return;
+ }
+ setKeywords((prev) => [...prev, kw]);
+ setKeywordDraft('');
+ }
+
+ function removeKeyword(kw: string) {
+ setKeywords((prev) => prev.filter((k) => k !== kw));
+ }
+
+ function onKeywordKeyDown(e: React.KeyboardEvent) {
+ // Enter or comma commits the draft chip; Backspace on an empty
+ // draft pops the last chip (familiar tag-input affordances).
+ if (e.key === 'Enter' || e.key === ',') {
+ e.preventDefault();
+ addKeyword(keywordDraft);
+ } else if (e.key === 'Backspace' && keywordDraft === '' && keywords.length) {
+ e.preventDefault();
+ setKeywords((prev) => prev.slice(0, -1));
+ }
+ }
+
+ async function onPickLogo(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ e.target.value = ''; // reset so the same file can be re-picked
+ if (!file) return;
+ if (!LOGO_ALLOWED_MIME.has(file.type)) {
+ toast.error('Unsupported image type', {
+ description: 'Use PNG, JPG, or WebP.',
+ });
+ return;
+ }
+ if (file.size > LOGO_MAX_BYTES) {
+ toast.error('Image is too large', { description: 'Maximum 2 MB.' });
+ return;
+ }
+ setUploadingLogo(true);
+ try {
+ const { publicUrl } = await uploadAccountMedia(LOGO_BUCKET, file);
+ setLogoUrl(publicUrl);
+ toast.success('Logo uploaded. Save to apply.');
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Upload failed');
+ } finally {
+ setUploadingLogo(false);
+ }
+ }
+
+ function removeLogo() {
+ setLogoUrl(null);
+ }
+
+ async function handleSave() {
+ const prompt = systemPrompt.trim();
+ if (!prompt) {
+ toast.error('The system prompt cannot be empty');
+ return;
+ }
+ const cap = Number(dailyReplyCap);
+ if (
+ !Number.isInteger(cap) ||
+ cap < 1 ||
+ cap > MAX_DAILY_REPLY_CAP
+ ) {
+ toast.error(
+ `Daily reply cap must be a whole number between 1 and ${MAX_DAILY_REPLY_CAP}`,
+ );
+ return;
+ }
+
+ setSaving(true);
+ try {
+ const res = await fetch('/api/ai/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ enabled,
+ system_prompt: prompt,
+ handoff_message: handoffMessage.trim() || null,
+ escalation_keywords: keywords,
+ business_name: businessName.trim() || null,
+ logo_url: logoUrl,
+ model,
+ daily_reply_cap: cap,
+ }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(data?.error || `Save failed (HTTP ${res.status})`);
+ }
+ seedFromConfig(data.config as AiAssistantConfig);
+ setApiKeyConfigured(Boolean(data.apiKeyConfigured));
+ toast.success('AI assistant settings saved');
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ // Dirty check — enables the Save button only when something changed
+ // from the last-saved snapshot. Keyword order is preserved on both
+ // sides, so a JSON compare is exact.
+ const dirty =
+ !!config &&
+ (enabled !== config.enabled ||
+ systemPrompt.trim() !== (config.system_prompt ?? '') ||
+ handoffMessage.trim() !== (config.handoff_message ?? '') ||
+ JSON.stringify(keywords) !==
+ JSON.stringify(config.escalation_keywords ?? []) ||
+ businessName.trim() !== (config.business_name ?? '') ||
+ (logoUrl ?? null) !== (config.logo_url ?? null) ||
+ model !== (config.model ?? 'claude-sonnet-4-6') ||
+ Number(dailyReplyCap) !== config.daily_reply_cap);
+
+ // Render the stored model as an option even if it's outside the known
+ // list, so a custom value set via the API isn't silently dropped.
+ const modelOptions = MODEL_OPTIONS.some((m) => m.value === model)
+ ? MODEL_OPTIONS
+ : [...MODEL_OPTIONS, { value: model, label: model }];
+
+ const initial = (businessName || 'A').charAt(0).toUpperCase();
+
+ if (loading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* API key notice — the assistant can't reply without it. */}
+ {!apiKeyConfigured && (
+
+
+
+
+
+ Anthropic API key not configured
+
+
+ The server has no ANTHROPIC_API_KEY{' '}
+ set, so the assistant can't send replies — every message
+ will be handed to a human. Set the key in the server
+ environment to enable auto-replies. You can still configure
+ everything else here.
+
+
+
+
+ )}
+
+ {/* Status & enable */}
+
+
+
+
+
+
+
+
+ Auto-reply to inbound WhatsApp messages
+
+
+ When on, the assistant answers confidently-grounded
+ questions and escalates anything it's unsure about to
+ a human. Off by default.
+
+
+
+
+
+ {enabled ? 'Enabled' : 'Disabled'}
+
+ setEnabled(next === true)}
+ aria-label="Enable the AI assistant"
+ />
+
+
+
+
+ {/* Prompt */}
+
+
+ Prompt & behaviour
+
+ The system prompt steers tone and the grounding rules. The
+ default already instructs the assistant to answer only from
+ your knowledge base and to escalate when unsure.
+
+
+
+
+ An inbound message containing any of these escalates straight
+ to a human — no AI reply attempted. Press Enter or comma to
+ add. Case-insensitive.
+
+
+
+
+
+ {/* Persona */}
+
+
+ Persona
+
+ How the assistant introduces itself. In v1 the logo is stored
+ as persona context only — there's no customer-facing
+ surface yet.
+
+
+
+
+ Substituted into the prompt wherever{' '}
+ {'{business_name}'} appears.
+
+
+
+
+
+ {/* Model & limits */}
+
+
+ Model & limits
+
+ Pick the Claude model and bound daily spend. Hitting the cap
+ escalates the rest of the day's messages to a human.
+
+
+
+
+
+
+
+ Sonnet is the recommended balance; Haiku is cheaper and
+ faster for simpler knowledge bases.
+
+ {overBudget ? (
+
+ Over the {fmtTokens(TOKEN_BUDGET)}-token budget — replies
+ cost more and may slow down. Trim entries, or this is the
+ signal to switch to retrieval (RAG).
+
+ ) : nearBudget ? (
+
+ Nearing the {fmtTokens(TOKEN_BUDGET)}-token budget. Consider
+ trimming or disabling lower-value entries.
+
+ ) : (
+ <>
+ Estimated from enabled entries (~4 characters per token).
+ {totalTokens !== enabledTokens
+ ? ` ${fmtTokens(totalTokens)} total including disabled.`
+ : ''}
+ >
+ )}
+
+
+
+
+ {entries.length === 0 ? (
+
+
+
+
+ No knowledge base entries yet.
+
+
+ Add a page or import a file so the assistant has something
+ to answer from.
+
+
+
+ )}
+
+ {/* Add / edit dialog */}
+
+
+ {/* Confirm-delete dialog */}
+
+
+ );
+}
diff --git a/src/components/settings/settings-overview.tsx b/src/components/settings/settings-overview.tsx
index fff559ac26..39d1f25f58 100644
--- a/src/components/settings/settings-overview.tsx
+++ b/src/components/settings/settings-overview.tsx
@@ -169,6 +169,11 @@ export function SettingsOverview({
>
),
},
+ {
+ section: 'ai',
+ loading: false,
+ subtitle: 'Auto-reply from your knowledge base',
+ },
{
section: 'members',
loading: countsLoading,
diff --git a/src/components/settings/settings-sections.ts b/src/components/settings/settings-sections.ts
index afc57229be..9877dd4c8a 100644
--- a/src/components/settings/settings-sections.ts
+++ b/src/components/settings/settings-sections.ts
@@ -1,4 +1,5 @@
import {
+ Bot,
Coins,
FileText,
KeyRound,
@@ -26,6 +27,7 @@ export const SETTINGS_SECTIONS = [
'security',
'appearance',
'whatsapp',
+ 'ai',
'templates',
'fields',
'deals',
@@ -51,6 +53,7 @@ export const SECTION_META: Record = {
security: { id: 'security', label: 'Login & security', icon: Shield, group: 'account' },
appearance: { id: 'appearance', label: 'Appearance', icon: Palette, group: 'account' },
whatsapp: { id: 'whatsapp', label: 'WhatsApp', icon: PlugZap, group: 'workspace' },
+ ai: { id: 'ai', label: 'AI Assistant', icon: Bot, group: 'workspace' },
templates: { id: 'templates', label: 'Templates', icon: FileText, group: 'workspace' },
fields: { id: 'fields', label: 'Fields & tags', icon: Tags, group: 'workspace' },
deals: { id: 'deals', label: 'Deals & currency', icon: Coins, group: 'workspace' },
From ab885eb2bc967776daf4ec90be3e1c703504a64c Mon Sep 17 00:00:00 2001
From: Martinxmaina
Date: Fri, 26 Jun 2026 00:43:54 +0300
Subject: [PATCH 5/8] fix(ai): address code-review findings + normalize new
files
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adversarial review (security + correctness) before PR surfaced one
blocking bug and several hardening items:
- CRITICAL: loadHistory included the just-inserted inbound row, which
buildMessages then appended again → two consecutive `user` turns →
Anthropic 400 → every reply silently escalated (AI never answered).
Exclude the trigger message (.neq('id', messageId)); add a regression
test that exercises real conversation history (the gap that hid it).
- Account-scope the orchestrator's conversation fetch (defence in depth
on the RLS-bypassing service-role client).
- Clear ai_escalated_at / ai_escalation_reason on a human reply so the
"Needs human" badge drops once handled (send route).
- Validate logo_url is http(s) only (reject javascript:/data:).
- Reject oversized uploads via Content-Length before buffering the body.
Also: normalize the new src/lib/ai + /api/ai + AI settings files to the
repo .prettierrc; document ANTHROPIC_API_KEY / AI_DEFAULT_MODEL in
.env.local.example and add a CI placeholder.
typecheck + lint(0 errors) + test(527) + build all green.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.env.local.example | 12 +
.github/workflows/ci.yml | 5 +
src/app/api/ai/config/route.ts | 24 +-
src/app/api/ai/knowledge/[id]/route.ts | 3 +-
src/app/api/ai/knowledge/upload/route.ts | 16 +
src/app/api/whatsapp/send/route.ts | 6 +
.../settings/ai-assistant-settings.tsx | 113 ++---
.../settings/knowledge-base-manager.tsx | 105 ++--
src/lib/ai/admin-client.ts | 10 +-
src/lib/ai/anthropic.ts | 56 +--
src/lib/ai/config.ts | 14 +-
src/lib/ai/decide.test.ts | 112 ++---
src/lib/ai/decide.ts | 15 +-
src/lib/ai/escalate.ts | 64 +--
src/lib/ai/guardrails.test.ts | 138 +++---
src/lib/ai/guardrails.ts | 84 ++--
src/lib/ai/knowledge-base.ts | 20 +-
src/lib/ai/prompt.test.ts | 252 +++++-----
src/lib/ai/prompt.ts | 41 +-
src/lib/ai/reply.test.ts | 456 +++++++++++-------
src/lib/ai/reply.ts | 217 +++++----
src/lib/ai/send.ts | 73 +--
22 files changed, 1017 insertions(+), 819 deletions(-)
diff --git a/.env.local.example b/.env.local.example
index 27e1e11312..b4720a8378 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -91,3 +91,15 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com
# Set this in CI and local development so you can exercise the full
# template UI without a real WABA. Leave unset (or "false") in prod.
# WHATSAPP_TEMPLATES_DRY_RUN=true
+
+# AI assistant (Settings → AI Assistant). Anthropic API key used to
+# auto-reply to inbound WhatsApp messages grounded in your knowledge
+# base. Leave blank to keep the assistant effectively off — without it
+# every inbound is handed straight to a human (the feature fails safe).
+# Get one at https://console.anthropic.com → API Keys.
+# ANTHROPIC_API_KEY=sk-ant-...
+
+# Optional override of the default model for AI replies. Defaults to
+# claude-sonnet-4-6; set claude-haiku-4-5 for a cheaper/faster option.
+# The model is also selectable per-account in Settings → AI Assistant.
+# AI_DEFAULT_MODEL=claude-sonnet-4-6
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b93bb25f44..9cd7d42ffe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,6 +29,11 @@ jobs:
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-dummy-anon-key
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
META_APP_SECRET: 'ci-dummy-meta-secret'
+ # The AI assistant reads ANTHROPIC_API_KEY lazily (only when a reply
+ # is actually generated), so build/typecheck/test don't strictly
+ # need it — but provide a placeholder for parity with the other
+ # secrets and to keep any future module-load read satisfied.
+ ANTHROPIC_API_KEY: 'ci-dummy-anthropic-key'
steps:
- uses: actions/checkout@v7
diff --git a/src/app/api/ai/config/route.ts b/src/app/api/ai/config/route.ts
index 3528fec64f..61d196c52b 100644
--- a/src/app/api/ai/config/route.ts
+++ b/src/app/api/ai/config/route.ts
@@ -284,7 +284,29 @@ export async function PUT(request: Request) {
{ status: 400 }
);
}
- updates.logo_url = url.length === 0 ? null : url;
+ if (url.length === 0) {
+ updates.logo_url = null;
+ } else {
+ // Only accept http(s) URLs — reject `javascript:` / `data:` and
+ // other schemes so a stored logo can never become an injection
+ // vector wherever it's later rendered as an .
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return NextResponse.json(
+ { error: "'logo_url' must be a valid URL" },
+ { status: 400 }
+ );
+ }
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
+ return NextResponse.json(
+ { error: "'logo_url' must be an http(s) URL" },
+ { status: 400 }
+ );
+ }
+ updates.logo_url = url;
+ }
} else {
return NextResponse.json(
{ error: "'logo_url' must be a string or null" },
diff --git a/src/app/api/ai/knowledge/[id]/route.ts b/src/app/api/ai/knowledge/[id]/route.ts
index e318e61cde..2f53e51c39 100644
--- a/src/app/api/ai/knowledge/[id]/route.ts
+++ b/src/app/api/ai/knowledge/[id]/route.ts
@@ -89,8 +89,7 @@ export async function PATCH(
const update: Record = {};
if ('title' in body) {
- const rawTitle =
- typeof body.title === 'string' ? body.title.trim() : '';
+ const rawTitle = typeof body.title === 'string' ? body.title.trim() : '';
if (!rawTitle) {
return NextResponse.json(
{ error: "'title' must be a non-empty string" },
diff --git a/src/app/api/ai/knowledge/upload/route.ts b/src/app/api/ai/knowledge/upload/route.ts
index b7823f15d8..cbd1919ac3 100644
--- a/src/app/api/ai/knowledge/upload/route.ts
+++ b/src/app/api/ai/knowledge/upload/route.ts
@@ -107,6 +107,22 @@ export async function POST(request: Request) {
);
if (!limit.success) return rateLimitResponse(limit);
+ // Reject oversized uploads before buffering the whole multipart body
+ // into memory. Content-Length can be understated by a hostile client
+ // but not inflated, so this is an early filter; the authoritative
+ // bound is the file.size check below after the body is parsed.
+ const declaredLength = Number(request.headers.get('content-length') ?? '0');
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_FILE_BYTES) {
+ return NextResponse.json(
+ {
+ error: `File is too large. Maximum size is ${Math.floor(
+ MAX_FILE_BYTES / (1024 * 1024)
+ )} MB.`,
+ },
+ { status: 413 }
+ );
+ }
+
const form = await request.formData().catch(() => null);
const file = form?.get('file');
if (!(file instanceof File)) {
diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts
index 6ab574aed8..f1e3481162 100644
--- a/src/app/api/whatsapp/send/route.ts
+++ b/src/app/api/whatsapp/send/route.ts
@@ -393,6 +393,10 @@ export async function POST(request: Request) {
// from here on, so the AI assistant goes silent on it immediately
// (mirrors the flow pause below — the strongest "human is here"
// signal). "Hand back to AI" in the inbox flips this back to true.
+ //
+ // Also clear any AI escalation markers: if the AI had escalated this
+ // thread (status='pending' + ai_escalated_at), a human now replying
+ // resolves it, so the "Needs human" badge drops instead of sticking.
await supabase
.from('conversations')
.update({
@@ -400,6 +404,8 @@ export async function POST(request: Request) {
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
ai_handling: false,
+ ai_escalated_at: null,
+ ai_escalation_reason: null,
})
.eq('id', conversation_id)
diff --git a/src/components/settings/ai-assistant-settings.tsx b/src/components/settings/ai-assistant-settings.tsx
index cad004d693..e1d77be828 100644
--- a/src/components/settings/ai-assistant-settings.tsx
+++ b/src/components/settings/ai-assistant-settings.tsx
@@ -43,11 +43,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
-} from '@/components/ui/avatar';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
Card,
CardContent,
@@ -81,11 +77,7 @@ const MODEL_OPTIONS: { value: string; label: string }[] = [
// migration 023), so restrict to those — GIF is not accepted there.
const LOGO_BUCKET = 'chat-media';
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
-const LOGO_ALLOWED_MIME = new Set([
- 'image/png',
- 'image/jpeg',
- 'image/webp',
-]);
+const LOGO_ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']);
// Bounds mirrored from the PUT /api/ai/config validator so the UI
// stops obvious over-length input before a round-trip.
@@ -105,11 +97,11 @@ export function AiAssistantSettings() {
/>
-
-
+
+
Only admins can configure the AI assistant.
-
+
Ask an account admin if you need changes made here.
@@ -214,7 +206,11 @@ function AiAssistantSettingsInner() {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addKeyword(keywordDraft);
- } else if (e.key === 'Backspace' && keywordDraft === '' && keywords.length) {
+ } else if (
+ e.key === 'Backspace' &&
+ keywordDraft === '' &&
+ keywords.length
+ ) {
e.preventDefault();
setKeywords((prev) => prev.slice(0, -1));
}
@@ -257,13 +253,9 @@ function AiAssistantSettingsInner() {
return;
}
const cap = Number(dailyReplyCap);
- if (
- !Number.isInteger(cap) ||
- cap < 1 ||
- cap > MAX_DAILY_REPLY_CAP
- ) {
+ if (!Number.isInteger(cap) || cap < 1 || cap > MAX_DAILY_REPLY_CAP) {
toast.error(
- `Daily reply cap must be a whole number between 1 and ${MAX_DAILY_REPLY_CAP}`,
+ `Daily reply cap must be a whole number between 1 and ${MAX_DAILY_REPLY_CAP}`
);
return;
}
@@ -329,7 +321,7 @@ function AiAssistantSettingsInner() {
description="Auto-reply to WhatsApp messages from your knowledge base, with a clean hand-off to a human when unsure."
/>
-
+
);
@@ -352,11 +344,11 @@ function AiAssistantSettingsInner() {
Anthropic API key not configured
- The server has no ANTHROPIC_API_KEY{' '}
- set, so the assistant can't send replies — every message
- will be handed to a human. Set the key in the server
- environment to enable auto-replies. You can still configure
- everything else here.
+ The server has no{' '}
+ ANTHROPIC_API_KEY set, so the
+ assistant can't send replies — every message will be handed
+ to a human. Set the key in the server environment to enable
+ auto-replies. You can still configure everything else here.
@@ -367,17 +359,17 @@ function AiAssistantSettingsInner() {
-
+
-
+
Auto-reply to inbound WhatsApp messages
-
- When on, the assistant answers confidently-grounded
- questions and escalates anything it's unsure about to
- a human. Off by default.
+
+ When on, the assistant answers confidently-grounded questions
+ and escalates anything it's unsure about to a human. Off by
+ default.
@@ -405,9 +397,9 @@ function AiAssistantSettingsInner() {
Prompt & behaviour
- The system prompt steers tone and the grounding rules. The
- default already instructs the assistant to answer only from
- your knowledge base and to escalate when unsure.
+ The system prompt steers tone and the grounding rules. The default
+ already instructs the assistant to answer only from your knowledge
+ base and to escalate when unsure.
@@ -422,9 +414,9 @@ function AiAssistantSettingsInner() {
maxLength={20_000}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="You are the customer-support assistant for…"
- className="resize-y border-border bg-muted text-sm text-foreground placeholder:text-muted-foreground"
+ className="border-border bg-muted text-foreground placeholder:text-muted-foreground resize-y text-sm"
/>
-
+
Use {'{business_name}'} to
insert the business name. The knowledge base is appended
automatically — don't paste it here.
@@ -442,21 +434,21 @@ function AiAssistantSettingsInner() {
maxLength={2_000}
onChange={(e) => setHandoffMessage(e.target.value)}
placeholder="Thanks! Let me connect you with a team member who can help."
- className="resize-y border-border bg-muted text-sm text-foreground placeholder:text-muted-foreground"
+ className="border-border bg-muted text-foreground placeholder:text-muted-foreground resize-y text-sm"
/>
-
- Sent to the customer when a conversation is escalated to a
- human. Leave blank to send nothing on hand-off.
+
+ Sent to the customer when a conversation is escalated to a human.
+ Leave blank to send nothing on hand-off.
-
+
{keywords.map((kw) => (
{kw}
)}
-
- An inbound message containing any of these escalates straight
- to a human — no AI reply attempted. Press Enter or comma to
- add. Case-insensitive.
+
+ An inbound message containing any of these escalates straight to a
+ human — no AI reply attempted. Press Enter or comma to add.
+ Case-insensitive.
@@ -510,9 +502,8 @@ function AiAssistantSettingsInner() {
Persona
- How the assistant introduces itself. In v1 the logo is stored
- as persona context only — there's no customer-facing
- surface yet.
+ How the assistant introduces itself. In v1 the logo is stored as
+ persona context only — there's no customer-facing surface yet.
@@ -521,7 +512,7 @@ function AiAssistantSettingsInner() {
{logoUrl ? (
) : null}
-
+
{initial}
@@ -558,7 +549,7 @@ function AiAssistantSettingsInner() {
Remove
)}
-
);
}
@@ -282,13 +286,12 @@ export function KnowledgeBaseManager() {
-
+
Knowledge base
-
- The assistant answers strictly from these pages. Disabled
- entries are excluded from the prompt. Import a file or write
- a page by hand.
+
+ The assistant answers strictly from these pages. Disabled entries
+ are excluded from the prompt. Import a file or write a page by hand.
@@ -327,7 +330,7 @@ export function KnowledgeBaseManager() {
-
+
Knowledge base size
{fmtTokens(enabledTokens)} / {fmtTokens(TOKEN_BUDGET)} tokens
0 ? 2 : 0)}%` }}
/>
-
+
{overBudget ? (
- Over the {fmtTokens(TOKEN_BUDGET)}-token budget — replies
- cost more and may slow down. Trim entries, or this is the
- signal to switch to retrieval (RAG).
+ Over the {fmtTokens(TOKEN_BUDGET)}-token budget — replies cost
+ more and may slow down. Trim entries, or this is the signal to
+ switch to retrieval (RAG).
) : nearBudget ? (
@@ -390,20 +393,20 @@ export function KnowledgeBaseManager() {
{entries.length === 0 ? (
-
-
+
+
No knowledge base entries yet.
-
- Add a page or import a file so the assistant has something
- to answer from.
+
+ Add a page or import a file so the assistant has something to
+ answer from.