# AI Assistant — Implementation Plan

**Spec:** docs/specs/2026-05-30-ai-assistant.md  ·  **Slug:** ai-assistant  ·  **Wave:** 3
**Depends on:** foundation-monorepo, foundation-auth-rbac, system-communications-notifications

## Goal
Deliver a tenant-aware AI assistant: in-app streaming chat, a Telegram AI flow, an Enterprise WhatsApp AI flow, and a programmatic OCR-categorization endpoint for the expenses module. Chat is context-aware via RAG — tenant business data (customers, invoices, tasks, projects, expenses, KB articles) is embedded into a per-tenant Cloudflare Vectorize namespace and retrieved per query. All model calls go through the existing `@zync/ai` infrastructure layer (`callAI` / `executeWithFallback` / `withCreditAccounting`) so credit accounting, fallback, and usage logging are inherited rather than re-implemented.

## Architecture
- **Consumes from `@zync/ai` (spec system-ai):** `callAI`, `executeWithFallback`, `withCreditAccounting`, `getTenantSettings`, `assembleSystemPrompt`, types `AIMessage`, `AIResponse`, `AIRequest`, `AIUseCase`, `AI_USE_CASES`. Use cases consumed: `ai_assistant`, `telegram_assistant`, `expense_ocr`. The `ai_usage_log`, `usage_counters` token tracking and tenant quota/credit logic already exist upstream — this spec does NOT re-create them; it calls `callAI` which logs internally and emits `tokens_used` per message.
- **Consumes from `system-communications-notifications`:** inbound webhook routes `POST /api/webhooks/telegram/:tenantId`, `POST /api/webhooks/whatsapp/:tenantId`; `routeInboundMessage`, `InboundMessage`, `OutboundMessage`, `CommsAdapter`, `TelegramNotificationAdapter`, `TenantCommsConfig`, `loadAdapterCredential`, `adapter_credentials`. The inbound queue consumer (`inbound_message_handler`) calls `dispatchToAiAssistant` (this spec) when `tenantConfig.aiAssistantEnabled`.
- **Consumes from `foundation-monorepo`:** bindings `AI` (Workers AI — embeddings), `VECTORIZE` (per-tenant namespace `tenant:{tenantId}`), `QUEUE` (Cloudflare Queues), `Env`, `createDb`, `DB`, `tenantQuery`, `systemQuery`, secret `ANTHROPIC_API_KEY`.
- **Consumes from `foundation-auth-rbac`:** `authMiddleware`, `requireTier`, `TenantTier`, `meetsMinimumTier`, `SessionPayload`, `requirePermission`. Tier gate: in-app chat + Telegram = `requireTier('business')`; WhatsApp = `requireTier('enterprise')`.
- **Consumes from `module-management`:** `requireModuleEnabled('ai_assistant')`, `ModuleId`.
- **New surfaces this spec owns:** `ai_chat_sessions`, `ai_chat_messages` tables; RAG indexer (`packages/ai/src/rag/`); two new queues (`ai_index_update`, `ai_telegram_message`); chat API routes; `POST /api/ai/categorize-expense`; in-app chat UI panel; index-update hooks invoked by entity modules.
- **Data flow (chat):** client → `POST /api/ai/chat` (SSE) → `retrieveContext` (embed query via Workers AI → Vectorize query topK=8) → assemble system prompt + last 20 messages → `callAI`/stream Claude → SSE deltas → persist user+assistant messages with `tokens_used`.
- **Data flow (index):** entity create/update/delete → enqueue `ai_index_update` job → queue consumer embeds text and upserts/deletes the vector in namespace `tenant:{tenantId}`.

## Tech Stack
- **App:** `apps/zync-api` (Hono on Cloudflare Workers) — chat routes, categorize endpoint, queue consumers, webhook AI dispatch.
- **App:** `apps/zync-app` (Vite + React) — floating chat button + slide-in Sheet panel, SSE consumer, markdown rendering.
- **Package:** `packages/ai` — extend with `rag/` (indexer + retrieval) and `chat/` (session helpers + prompt assembly for the assistant use case).
- **Libraries:** `@anthropic-ai/sdk` (already used by `@zync/ai` anthropic adapter, streaming), `react-markdown` + `remark-gfm` (assistant message rendering), Drizzle ORM.
- **Bindings:** `AI` (Workers AI `@cf/baai/bge-small-en-v1.5`, 384-dim), `VECTORIZE`, `QUEUE`, Hyperdrive→Neon, secret `ANTHROPIC_API_KEY`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | `packages/db/src/schema/ai-chat.ts`, migration | No (first) |
| B — RAG core | 2, 3 | `packages/ai/src/rag/*` | 2 then 3 |
| C — index queue | 4, 5 | queue consumer, index hooks, `wrangler.toml` | After B |
| D — chat backend | 6, 7, 8 | `apps/zync-api/src/routes/ai/*`, query helpers | 6 after B; 7,8 after 6 |
| E — telegram/whatsapp | 9, 10 | webhook AI dispatch, telegram queue consumer | After D |
| F — OCR endpoint | 11 | categorize route | After A (parallel w/ E) |
| G — chat UI | 12, 13 | `apps/zync-app/src/features/ai-chat/*` | After D |
| H — wiring/tests | 14, 15 | bindings, integration tests | Last |

## Tasks

### Task 1: Chat session & message schema + migration
**Blocks:** 6, 7, 8, 9, 14  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/ai-chat.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export)
- Create: `packages/db/migrations/<timestamp>_ai_chat.sql`
**Steps:**
- [ ] Define `ai_chat_sessions` and `ai_chat_messages` Drizzle tables in canonical Postgres dialect.
- [ ] Add the `role` CHECK constraint and the `telegram_chat_id` metadata column needed by the Telegram flow (last-5 lookup by `metadata.telegram_chat_id`).
- [ ] Add indexes: sessions by `(tenant_id, user_id, created_at DESC)`; messages by `(session_id, created_at)`; partial/GIN index on `metadata` for the Telegram lookup.
- [ ] Re-export from schema index; generate migration via drizzle-kit.
**Schema / Interfaces:**
```sql
CREATE TABLE ai_chat_sessions (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  title       TEXT,                       -- derived from first user message; nullable
  channel     TEXT NOT NULL DEFAULT 'in_app'
              CHECK (channel IN ('in_app', 'telegram', 'whatsapp')),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ai_chat_sessions_user ON ai_chat_sessions (tenant_id, user_id, created_at DESC);

CREATE TABLE ai_chat_messages (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  session_id  UUID NOT NULL REFERENCES ai_chat_sessions(id) ON DELETE CASCADE,
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  role        TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
  content     TEXT NOT NULL,
  tokens_used INTEGER,
  metadata    JSONB NOT NULL DEFAULT '{}',  -- { "telegram_chat_id": "...", "model": "..." }
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ai_chat_messages_session ON ai_chat_messages (session_id, created_at);
CREATE INDEX ai_chat_messages_telegram
  ON ai_chat_messages ((metadata->>'telegram_chat_id'), created_at DESC)
  WHERE metadata ? 'telegram_chat_id';
```
**Acceptance:**
- [ ] Migration applies on Neon; both tables exist with UUID PKs and UUID→UUID FKs.
- [ ] `role` and `channel` CHECK constraints reject invalid values.
- [ ] Telegram lookup index is usable by an `EXPLAIN` of the last-5 query.

### Task 2: RAG embedding + indexable-entity contract
**Blocks:** 3, 4, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/ai/src/rag/embed.ts`
- Create: `packages/ai/src/rag/types.ts`
- Modify: `packages/ai/src/index.ts` (export RAG public API)
**Steps:**
- [ ] Implement `embedText(env, text)` calling Workers AI `@cf/baai/bge-small-en-v1.5`, returning the 384-dim vector (`result.data[0]`).
- [ ] Define `IndexableEntity` describing the 6 indexed entity kinds and the text each contributes (per spec table: customers=name/email/contact notes; invoices=amount/status/customer/line items; tasks=title/description/status/assignee; projects=name/type/customer/description; expenses=amount/category/description; kb=full text).
- [ ] Define the Vectorize vector ID convention: `${entityType}:${entityId}` so updates upsert in place and deletes remove by ID.
- [ ] Export `embedText`, `IndexableEntity`, `IndexableEntityType`, `vectorIdFor`.
**Schema / Interfaces:**
```ts
export type IndexableEntityType =
  | 'customer' | 'invoice' | 'task' | 'project' | 'expense' | 'kb_article';

export interface IndexableEntity {
  tenantId: string;
  entityType: IndexableEntityType;
  entityId: string;        // UUID of the source row
  text: string;            // flattened text to embed (built by source module)
}

export function vectorIdFor(t: IndexableEntityType, id: string): string; // `${t}:${id}`

export async function embedText(env: Env, text: string): Promise<number[]>; // 384-dim
```
**Acceptance:**
- [ ] `embedText` returns a 384-length number array for non-empty input.
- [ ] `vectorIdFor('customer', uuid)` is stable across calls.

### Task 3: Context retrieval (`retrieveContext`)
**Blocks:** 6, 9  ·  **Blocked by:** 2
**Files:**
- Create: `packages/ai/src/rag/retrieve.ts`
- Modify: `packages/ai/src/index.ts` (export)
**Steps:**
- [ ] Implement `retrieveContext(env, tenantId, query)`: embed query, `VECTORIZE.query(vector, { namespace: 'tenant:${tenantId}', topK: 8, returnMetadata: true })`, map matches to `metadata.text`, filter falsy, join with `\n\n---\n\n`.
- [ ] Return `''` (not throw) when the namespace is empty so chat still works for new tenants.
- [ ] Export `retrieveContext`.
**Schema / Interfaces:**
```ts
export async function retrieveContext(
  env: Env,
  tenantId: string,
  query: string
): Promise<string>;  // joined top-8 chunk texts, '' if none
```
**Acceptance:**
- [ ] Querying an empty namespace returns `''` without error.
- [ ] Namespace is always `tenant:${tenantId}` — verified by unit test asserting the query args.

### Task 4: `ai_index_update` queue consumer
**Blocks:** 14  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/queues/ai-index-update.ts`
- Modify: `apps/zync-api/src/index.ts` (register queue handler in `queue()` export)
- Modify: `apps/zync-api/wrangler.toml` (queue producer + consumer binding)
**Steps:**
- [ ] Define the job payload `AiIndexUpdateJob` (`upsert` carries text; `delete` carries only the vector ID).
- [ ] Consumer for `upsert`: `embedText` → `VECTORIZE.upsert([{ id: vectorIdFor(...), values, namespace: 'tenant:${tenantId}', metadata: { text, entity_type, entity_id } }])`.
- [ ] Consumer for `delete`: `VECTORIZE.deleteByIds([vectorIdFor(entityType, entityId)])` scoped to tenant namespace.
- [ ] Register `ai_index_update` in the Worker `queue()` switch keyed by queue name; ack on success, retry on transient error.
**Schema / Interfaces:**
```ts
export type AiIndexUpdateJob =
  | { op: 'upsert'; tenantId: string; entityType: IndexableEntityType; entityId: string; text: string }
  | { op: 'delete'; tenantId: string; entityType: IndexableEntityType; entityId: string };

export async function handleAiIndexUpdate(batch: MessageBatch<AiIndexUpdateJob>, env: Env): Promise<void>;
```
**Acceptance:**
- [ ] An `upsert` job results in a queryable vector in the correct tenant namespace.
- [ ] A `delete` job removes that vector by ID.
- [ ] Malformed/transient failures retry; permanent failures (bad payload) ack to avoid poison loops.

### Task 5: Index-update enqueue helper + entity hooks
**Blocks:** 14  ·  **Blocked by:** 4
**Files:**
- Create: `packages/ai/src/rag/enqueue.ts`
- Modify: `packages/ai/src/index.ts` (export)
**Steps:**
- [ ] Implement `enqueueIndexUpsert(env, entity)` and `enqueueIndexDelete(env, { tenantId, entityType, entityId })` that put `AiIndexUpdateJob` onto `QUEUE`.
- [ ] Provide per-entity text builders (`buildCustomerIndexText`, `buildInvoiceIndexText`, `buildTaskIndexText`, `buildProjectIndexText`, `buildExpenseIndexText`, `buildKbArticleIndexText`) matching the spec's indexed-fields table, so source modules (expenses, projects, etc.) call one function on create/update/delete.
- [ ] Export all enqueue helpers and text builders so downstream module specs invoke them in their write paths.
**Schema / Interfaces:**
```ts
export async function enqueueIndexUpsert(env: Env, entity: IndexableEntity): Promise<void>;
export async function enqueueIndexDelete(
  env: Env,
  ref: { tenantId: string; entityType: IndexableEntityType; entityId: string }
): Promise<void>;
export function buildCustomerIndexText(c: { name: string; email?: string; notes?: string }): string;
export function buildInvoiceIndexText(i: { number: string; amount: string; status: string; customerName?: string; lineItems: string[] }): string;
export function buildTaskIndexText(t: { title: string; description?: string; status: string; assigneeName?: string }): string;
export function buildProjectIndexText(p: { name: string; type?: string; customerName?: string; description?: string }): string;
export function buildExpenseIndexText(e: { amount: string; category?: string; description?: string }): string;
export function buildKbArticleIndexText(a: { title: string; body: string }): string;
```
**Acceptance:**
- [ ] Calling `enqueueIndexUpsert` puts exactly one well-formed job on `QUEUE`.
- [ ] Text builders include every field named in the spec's "Indexed entities" table.

### Task 6: Chat orchestration + session/message query helpers
**Blocks:** 7, 8, 9  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `packages/ai/src/chat/assistant.ts`
- Create: `packages/db/src/queries/ai-chat.ts`
- Modify: `packages/db/src/queries/index.ts` (extend `tenantQuery` factory with `aiChat`)
- Modify: `packages/ai/src/index.ts` (export)
**Steps:**
- [ ] Implement `runAssistantTurn` (non-streaming) and `streamAssistantTurn` (streaming): retrieve context, build the assistant system prompt from the spec template (`{{tenantName}}`, `{{retrievedContext}}`, `{{date}}`, `{{currency}}`), load last 20 messages for the session, call `@zync/ai` with `useCase: 'ai_assistant'` so `withCreditAccounting` applies.
- [ ] Add tenant-scoped query helpers under `tenantQuery(db, tenantId).aiChat`: `createSession`, `getSession`, `listSessions`, `deleteSession`, `appendMessage`, `lastMessages(sessionId, n)`, and `lastTelegramMessages(chatId, n)` for the Telegram flow.
- [ ] Persist user message before the model call; persist assistant message with `tokens_used` (= `inputTokens + outputTokens` from `AIResponse`) after completion.
- [ ] Pull `tenantName`/`currency`/`date` from tenant row (`tenants.default_currency`, tenant timezone) via `tenantQuery`.
**Schema / Interfaces:**
```ts
export function buildAssistantSystemPrompt(args: {
  tenantName: string; retrievedContext: string; date: string; currency: string;
}): string;

export async function streamAssistantTurn(env: Env, args: {
  tenantId: string; userId: string; sessionId: string; message: string;
}): Promise<ReadableStream<string>>;  // emits assistant text deltas

export async function runAssistantTurn(env: Env, args: {
  tenantId: string; userId?: string; channel: 'telegram' | 'whatsapp';
  message: string; telegramChatId?: string;
}): Promise<{ reply: string; tokensUsed: number }>;

// tenantQuery(db, tenantId).aiChat:
interface AiChatQueries {
  createSession(input: { userId: string; channel: 'in_app' | 'telegram' | 'whatsapp'; title?: string }): Promise<{ id: string }>;
  getSession(sessionId: string): Promise<{ id: string; userId: string; channel: string } | null>;
  listSessions(userId: string, limit: number): Promise<Array<{ id: string; title: string | null; updatedAt: string }>>;
  deleteSession(sessionId: string): Promise<void>;
  appendMessage(input: { sessionId: string; role: 'user' | 'assistant' | 'system'; content: string; tokensUsed?: number; metadata?: Record<string, unknown> }): Promise<{ id: string }>;
  lastMessages(sessionId: string, n: number): Promise<Array<{ role: string; content: string }>>;
  lastTelegramMessages(telegramChatId: string, n: number): Promise<Array<{ role: string; content: string }>>;
}
```
**Acceptance:**
- [ ] System prompt matches the spec template verbatim with placeholders substituted.
- [ ] Conversation context is capped at the last 20 messages.
- [ ] Assistant message rows store non-null `tokens_used` after a successful call.

### Task 7: `POST /api/ai/chat` SSE streaming route
**Blocks:** 12, 14  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-api/src/routes/ai/chat.ts`
- Modify: `apps/zync-api/src/routes/ai/index.ts` (mount)
- Modify: `apps/zync-api/src/index.ts` (register `/api/ai` router)
**Steps:**
- [ ] Apply middleware chain: `authMiddleware`, `requireModuleEnabled('ai_assistant')`, `requireTier('business')`.
- [ ] Validate body with zod: `{ sessionId?: string (uuid), message: string (1..4000) }`.
- [ ] If no `sessionId`, create a session (channel `in_app`, title from first ~60 chars of message).
- [ ] Return `text/event-stream`; pipe `streamAssistantTurn` deltas as `data: {"type":"delta","content":"..."}`; on completion emit `data: {"type":"done","usage":{"inputTokens":N,"outputTokens":N}}`; on error emit `data: {"type":"error","message":"..."}` and close.
- [ ] Set SSE headers including `Cache-Control: no-cache`, `Connection: keep-alive`, and a CSP-compatible content type; never echo secrets in the error message.
**Schema / Interfaces:**
```ts
// Request body
const chatBody = z.object({ sessionId: z.string().uuid().optional(), message: z.string().min(1).max(4000) });
// SSE events:
//   { type: 'delta', content: string }
//   { type: 'done', usage: { inputTokens: number, outputTokens: number } }
//   { type: 'error', message: string }
```
**Acceptance:**
- [ ] Freelancer tenant gets 403 (tier gate); Business+ streams deltas.
- [ ] Disabling the `ai_assistant` module returns the module-disabled response.
- [ ] Stream ends with a single `done` event carrying token usage.

### Task 8: Chat session management routes
**Blocks:** 12, 14  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-api/src/routes/ai/sessions.ts`
- Modify: `apps/zync-api/src/routes/ai/index.ts` (mount)
**Steps:**
- [ ] `GET /api/ai/chat/sessions` → `listSessions(userId, 50)` for the current user/tenant.
- [ ] `GET /api/ai/chat/sessions/:id` → session + ordered messages; 404 if session not in tenant/user scope.
- [ ] `DELETE /api/ai/chat/sessions/:id` → cascade delete session + messages (DB FK ON DELETE CASCADE); 204.
- [ ] All routes behind `authMiddleware` + `requireModuleEnabled('ai_assistant')` + `requireTier('business')`; enforce ownership (session `user_id` === session user) so users can't read others' sessions.
**Schema / Interfaces:**
```
GET    /api/ai/chat/sessions       -> { sessions: Array<{ id, title, updatedAt }> }
GET    /api/ai/chat/sessions/:id   -> { session: {...}, messages: Array<{ id, role, content, createdAt }> }
DELETE /api/ai/chat/sessions/:id   -> 204
```
**Acceptance:**
- [ ] Listing returns only the requesting user's sessions within the tenant.
- [ ] Fetching another user's session id returns 404.
- [ ] Deleting a session removes its messages.

### Task 9: Telegram AI dispatch + `ai_telegram_message` consumer
**Blocks:** 14  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-api/src/queues/ai-telegram-message.ts`
- Create: `apps/zync-api/src/ai/dispatch-to-assistant.ts`
- Modify: `apps/zync-api/src/index.ts` (register `ai_telegram_message` consumer)
- Modify: `apps/zync-api/wrangler.toml` (queue producer + consumer)
**Steps:**
- [ ] Implement `dispatchToAiAssistant(env, msg, tenantConfig)` — called by the comms inbound consumer when `tenantConfig.aiAssistantEnabled`. It checks `tenantConfig.aiAssistantEnabled === true` and that the inbound chat matches `telegram_ai_chat_id`, then enqueues an `AiTelegramMessageJob`.
- [ ] `ai_telegram_message` consumer: load last 5 messages where `metadata.telegram_chat_id = chatId` (`lastTelegramMessages`), run `runAssistantTurn({ channel: 'telegram', telegramChatId, message })` with `useCase` mapped to `telegram_assistant`, persist user+assistant messages stamped with `metadata.telegram_chat_id`, then reply via the tenant Telegram adapter `sendMessage` (load bot token through `loadAdapterCredential(tenantId, 'telegram')` / `TelegramNotificationAdapter`).
- [ ] No persistent in-app session for Telegram — history is the Telegram thread (last 5 from DB by `telegram_chat_id`).
- [ ] Tier guard inside dispatch: skip (no reply) if tenant is below Business.
**Schema / Interfaces:**
```ts
export interface AiTelegramMessageJob {
  tenantId: string;
  telegramChatId: string;
  message: string;
}
export async function dispatchToAiAssistant(
  env: Env, msg: InboundMessage, tenantConfig: TenantCommsConfig
): Promise<void>;
export async function handleAiTelegramMessage(
  batch: MessageBatch<AiTelegramMessageJob>, env: Env
): Promise<void>;
```
**Acceptance:**
- [ ] Inbound Telegram message to an AI-enabled tenant triggers exactly one `ai_telegram_message` job.
- [ ] Consumer replies via the tenant's own bot token and persists both messages with `telegram_chat_id` metadata.
- [ ] Tenant below Business produces no AI reply.

### Task 10: WhatsApp AI flow (Enterprise)
**Blocks:** 14  ·  **Blocked by:** 9
**Files:**
- Modify: `apps/zync-api/src/ai/dispatch-to-assistant.ts` (channel branch)
- Modify: `apps/zync-api/src/queues/ai-telegram-message.ts` → generalize into channel-aware reply, or add `apps/zync-api/src/ai/reply-channel.ts`
**Steps:**
- [ ] Extend `dispatchToAiAssistant` to handle `msg.channel === 'whatsapp'`: gate with `meetsMinimumTier(tier, 'enterprise')`; below Enterprise = no reply.
- [ ] Reuse the same `runAssistantTurn` with `channel: 'whatsapp'`; reply through the WhatsApp adapter (`POST graph.facebook.com/.../messages`) using the tenant WABA token from `loadAdapterCredential(tenantId, 'whatsapp')`.
- [ ] Persist user+assistant messages with `metadata.whatsapp_chat_id`.
- [ ] WhatsApp config endpoint remains gated by `requireTier('enterprise')` (owned by comms/settings); this spec only wires the AI reply branch.
**Schema / Interfaces:**
```ts
// runAssistantTurn channel union extended: 'telegram' | 'whatsapp'
// reply dispatch chooses adapter by channel; metadata key 'whatsapp_chat_id'
```
**Acceptance:**
- [ ] Business-tier WhatsApp inbound produces no AI reply (Enterprise gate holds).
- [ ] Enterprise inbound replies via the WhatsApp adapter and persists both messages.

### Task 11: `POST /api/ai/categorize-expense` endpoint
**Blocks:** 14  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/ai/categorize-expense.ts`
- Modify: `apps/zync-api/src/routes/ai/index.ts` (mount)
**Steps:**
- [ ] `authMiddleware` + `requireTier('business')` (OCR categorization is Business+); no RAG, no session.
- [ ] Validate body with zod: `{ extractedText: string (1..20000), existingCategories: string[] }`.
- [ ] Single `callAI({ useCase: 'expense_ocr', messages: [...] })` with a focused prompt that asks for category, isPersonal, taxHint, confidence; parse the JSON response.
- [ ] Respond `{ category, isPersonal, taxHint, confidence }`; on parse failure return a safe default with `confidence: 0`.
- [ ] Token usage logged automatically by `callAI` (no separate accounting here).
**Schema / Interfaces:**
```ts
const body = z.object({
  extractedText: z.string().min(1).max(20000),
  existingCategories: z.array(z.string()),
});
// Response
interface CategorizeExpenseResult {
  category: string;
  isPersonal: boolean;
  taxHint: string;
  confidence: number; // 0..1
}
```
**Acceptance:**
- [ ] Returns the four-field result for valid input.
- [ ] Prefers an existing category name when the text matches one.
- [ ] Below-Business tenants get 403.

### Task 12: In-app chat UI panel
**Blocks:** 13, 14  ·  **Blocked by:** 7, 8
**Files:**
- Create: `apps/zync-app/src/features/ai-chat/ChatPanel.tsx`
- Create: `apps/zync-app/src/features/ai-chat/ChatLauncher.tsx`
- Create: `apps/zync-app/src/features/ai-chat/MessageBubble.tsx`
- Create: `apps/zync-app/src/features/ai-chat/useChatStream.ts`
- Create: `apps/zync-app/src/features/ai-chat/useChatSessions.ts`
- Modify: `apps/zync-app/src/App.tsx` (mount launcher for Business+ users)
**Steps:**
- [ ] `ChatLauncher`: floating button bottom-right (logical end / inset-inline-end for RTL), rendered only when `useTierGate('business').allowed` and module `ai_assistant` enabled (`useModuleEnabled`).
- [ ] `ChatPanel`: slide-in `Sheet` (right side, `xl` width) from `@zync/ui`; message list, input, "New conversation" button.
- [ ] `useChatStream`: POST to `/api/ai/chat`, read the SSE body via `ReadableStream`/`TextDecoder`, append `delta` content incrementally, finalize on `done`, surface `error`.
- [ ] `MessageBubble`: user vs assistant styling; assistant rendered with `react-markdown` + `remark-gfm` (code blocks, lists).
- [ ] Persist active `sessionId` in memory/store so it survives navigation; clear on "New conversation".
- [ ] Use design tokens only (no hardcoded colors/spacing/radius); respect `prefers-reduced-motion` for the slide animation; aria roles (`role="log"` on message list, labelled controls); RTL-safe layout.
**Schema / Interfaces:** —
**Acceptance:**
- [ ] Launcher hidden for Freelancer/module-disabled; visible for Business+.
- [ ] Assistant responses render markdown and stream token-by-token.
- [ ] Reduced-motion users get no slide animation; panel is keyboard-accessible.

### Task 13: Session list & history in chat UI
**Blocks:** 14  ·  **Blocked by:** 12
**Files:**
- Create: `apps/zync-app/src/features/ai-chat/SessionList.tsx`
- Modify: `apps/zync-app/src/features/ai-chat/ChatPanel.tsx`
**Steps:**
- [ ] `SessionList` consumes `GET /api/ai/chat/sessions`; selecting a session loads `GET /api/ai/chat/sessions/:id` into the panel.
- [ ] Delete control calls `DELETE /api/ai/chat/sessions/:id` and refreshes the list.
- [ ] Empty-state (no sessions) uses the shared `EmptyState` component.
**Schema / Interfaces:** —
**Acceptance:**
- [ ] Switching sessions replaces the message list with that session's history.
- [ ] Deleting the active session resets to a fresh conversation.

### Task 14: Bindings, queue registration & module wiring
**Blocks:** 15  ·  **Blocked by:** 4, 5, 7, 8, 9, 10, 11, 12, 13
**Files:**
- Modify: `apps/zync-api/wrangler.toml`
- Modify: `apps/zync-api/src/index.ts`
- Modify: `apps/zync-app/wrangler.toml` (if app proxies SSE)
**Steps:**
- [ ] Declare `[[vectorize]]` binding `VECTORIZE` (index with 384 dims, cosine) and `[ai]` binding `AI` in `apps/zync-api/wrangler.toml`.
- [ ] Declare queue producers (`ai_index_update`, `ai_telegram_message`) and `[[queues.consumers]]` for both with the Worker; confirm `ANTHROPIC_API_KEY` secret is wired.
- [ ] Register the `/api/ai` router and the `queue()` dispatch for both new queues; ensure the comms inbound consumer calls `dispatchToAiAssistant`.
- [ ] Confirm CSP allows the SSE endpoint and that no inline scripts are introduced by the chat UI.
**Schema / Interfaces:** —
**Acceptance:**
- [ ] `wrangler deploy --dry-run` resolves all bindings (`AI`, `VECTORIZE`, `QUEUE`, secret).
- [ ] Both queues have a registered consumer; the inbound consumer reaches `dispatchToAiAssistant`.

### Task 15: Integration tests
**Blocks:** —  ·  **Blocked by:** 14
**Files:**
- Create: `apps/zync-api/test/ai-chat.test.ts`
- Create: `apps/zync-api/test/ai-rag.test.ts`
- Create: `apps/zync-api/test/ai-telegram.test.ts`
**Steps:**
- [ ] Tier gating: Freelancer → 403 on `/api/ai/chat` and `/api/ai/categorize-expense`; Business → 200/stream.
- [ ] RAG: enqueue upsert → consumer writes vector → `retrieveContext` returns the chunk for a matching query; delete removes it.
- [ ] Chat: streaming yields `delta`s then a single `done` with usage; messages persisted with `tokens_used`; session ownership enforced (other user → 404).
- [ ] Telegram: AI-enabled inbound enqueues one `ai_telegram_message` job; consumer replies via mocked tenant bot token and stores `telegram_chat_id` metadata; below-Business no reply.
- [ ] Categorize: returns `{ category, isPersonal, taxHint, confidence }` and prefers an existing category.
**Schema / Interfaces:** —
**Acceptance:**
- [ ] All listed tests pass against a Miniflare/Workers test harness with mocked `AI`, `VECTORIZE`, and Claude responses.
