> ## Documentation Index
> Fetch the complete documentation index at: https://devdocs.aiconnected.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# aiConnected OS — Product Requirements Document

> v5

`````md theme={null}
# aiConnected OS — Product Requirements Document (Outline) 

**Version:** 1.0 Draft **Last Updated:** February 13, 2026 **Author:** Bob (Oxford Pierpont) **Build Target:** Claude Code \+ Supabase **UI Reference:** `aiconnected-os-v5-final.jsx` (3,038-line interactive prototype)

# Table Of Contents  

[aiConnected OS — Product Requirements Document (Outline)	1](#aiconnected-os-—-product-requirements-document-\(outline\))

[Table Of Contents	2](#table-of-contents)

[Document Purpose	22](#document-purpose)

[PART 1: PRODUCT FOUNDATION	22](#part-1:-product-foundation)

[1.1 Product Vision & Definition	22](#1.1-product-vision-&-definition)

[1.2 Architecture Overview (Four Layers)	22](#1.2-architecture-overview-\(four-layers\))

[1.3 Technology Stack	22](#1.3-technology-stack)

[1.4 Design System & Theming	23](#1.4-design-system-&-theming)

[PART 2: AUTHENTICATION & USER MANAGEMENT	23](#part-2:-authentication-&-user-management)

[2.1 Authentication	23](#2.1-authentication)

[2.2 User Profile	23](#2.2-user-profile)

[2.3 Pricing Tiers & Feature Gating	23](#2.3-pricing-tiers-&-feature-gating)

[2.4 Onboarding Flow	24](#2.4-onboarding-flow)

[PART 3: APPLICATION SHELL & NAVIGATION	24](#part-3:-application-shell-&-navigation)

[3.1 Sidebar Navigation	24](#3.1-sidebar-navigation)

[3.2 Top Bar	24](#3.2-top-bar)

[3.3 Command Palette	24](#3.3-command-palette)

[3.4 Notification System	25](#3.4-notification-system)

[3.5 Keyboard Shortcuts	25](#3.5-keyboard-shortcuts)

[PART 4: HOME / DASHBOARD SCREEN	25](#part-4:-home-/-dashboard-screen)

[4.1 Layout	25](#4.1-layout)

[4.2 Quick Stats	25](#4.2-quick-stats)

[4.3 Recent Activity Feed	25](#4.3-recent-activity-feed)

[4.4 Empty State (First-Time User)	26](#4.4-empty-state-\(first-time-user\))

[PART 5: CHAT SYSTEM (Core)	26](#part-5:-chat-system-\(core\))

[5.1 Data Model	26](#5.1-data-model)

[5.2 Chat List Drawer (Left Panel)	26](#5.2-chat-list-drawer-\(left-panel\))

[5.3 Message List	26](#5.3-message-list)

[5.4 Message Composer	27](#5.4-message-composer)

[5.5 Auto-Rename & Suggested Move Banners	27](#5.5-auto-rename-&-suggested-move-banners)

[5.6 Right Panel (Sidebar)	27](#5.6-right-panel-\(sidebar\))

[5.7 Export System	27](#5.7-export-system)

[5.8 AI Response Pipeline	28](#5.8-ai-response-pipeline)

[PART 6: SEARCH SCREEN	28](#part-6:-search-screen)

[6.1 Layout	28](#6.1-layout)

[6.2 Search Implementation	28](#6.2-search-implementation)

[6.3 Search Results	28](#6.3-search-results)

[6.4 Search History & Saved Searches	28](#6.4-search-history-&-saved-searches)

[PART 7: SPACES / INSTANCES SCREEN	29](#part-7:-spaces-/-instances-screen)

[7.1 Data Model	29](#7.1-data-model)

[7.2 Instance List	29](#7.2-instance-list)

[7.3 Instance Detail View	29](#7.3-instance-detail-view)

[7.3.1 Overview Tab	29](#7.3.1-overview-tab)

[7.3.2 Tasks Tab	29](#7.3.2-tasks-tab)

[7.3.3 Workspace Tab	30](#7.3.3-workspace-tab)

[7.3.4 Chats Tab	30](#7.3.4-chats-tab)

[7.3.5 Files Tab	30](#7.3.5-files-tab)

[7.3.6 Settings Tab	30](#7.3.6-settings-tab)

[PART 8: GLOBAL FILES SCREEN	30](#part-8:-global-files-screen)

[8.1 Data Model	30](#8.1-data-model)

[8.2 File Grid View	31](#8.2-file-grid-view)

[8.3 File Upload	31](#8.3-file-upload)

[PART 9: PEOPLE / PERSONAS SCREEN	31](#part-9:-people-/-personas-screen)

[9.1 Data Model	31](#9.1-data-model)

[9.2 Persona List View	31](#9.2-persona-list-view)

[9.3 Persona Detail View	32](#9.3-persona-detail-view)

[9.3.1 Overview Tab	32](#9.3.1-overview-tab)

[9.3.2 Identity Tab	32](#9.3.2-identity-tab)

[9.3.3 Boundaries Tab	32](#9.3.3-boundaries-tab)

[9.3.4 Memory Tab	32](#9.3.4-memory-tab)

[9.3.5 Skills Tab	32](#9.3.5-skills-tab)

[9.3.6 Health Tab	32](#9.3.6-health-tab)

[9.4 Create Persona Modal	33](#9.4-create-persona-modal)

[PART 10: TEAMS SCREEN (Agentic Teams)	33](#part-10:-teams-screen-\(agentic-teams\))

[10.1 Data Model	33](#10.1-data-model)

[10.2 Teams List	33](#10.2-teams-list)

[10.3 Team Detail (Future — v2)	33](#10.3-team-detail-\(future-—-v2\))

[PART 11: BROWSER / CO-BROWSING WORKSPACE	34](#part-11:-browser-/-co-browsing-workspace)

[11.1 Architecture Overview	34](#11.1-architecture-overview)

[11.2 Data Model	34](#11.2-data-model)

[11.3 Session Manager Screen (Non-Browse Mode)	34](#11.3-session-manager-screen-\(non-browse-mode\))

[11.4 Browser Engine (Core — MUST BE FULLY FUNCTIONAL)	34](#11.4-browser-engine-\(core-—-must-be-fully-functional\))

[11.4.1 Web Viewport	34](#11.4.1-web-viewport)

[11.4.2 Navigation System	35](#11.4.2-navigation-system)

[11.4.3 Floating Navigation Bar	35](#11.4.3-floating-navigation-bar)

[11.4.4 Page Interaction Layer	35](#11.4.4-page-interaction-layer)

[11.4.5 Security & Limitations	36](#11.4.5-security-&-limitations)

[11.5 Co-Browse Chat Integration (5 View Modes)	36](#11.5-co-browse-chat-integration-\(5-view-modes\))

[11.5.1 Float Bar (Default)	36](#11.5.1-float-bar-\(default\))

[11.5.2 Icon Only	36](#11.5.2-icon-only)

[11.5.3 Sidebar (320px)	36](#11.5.3-sidebar-\(320px\))

[11.5.4 50/50 Split	36](#11.5.4-50/50-split)

[11.5.5 Chat Only	37](#11.5.5-chat-only)

[11.6 Page Awareness System	37](#11.6-page-awareness-system)

[11.7 View Switcher Menu	37](#11.7-view-switcher-menu)

[PART 12: INSIGHTS & ANALYTICS SCREEN	37](#part-12:-insights-&-analytics-screen)

[12.1 Overview Tab	37](#12.1-overview-tab)

[12.2 Persona Performance Tab	37](#12.2-persona-performance-tab)

[12.3 Model Usage Tab	37](#12.3-model-usage-tab)

[12.4 Memory Health Tab	37](#12.4-memory-health-tab)

[PART 13: SETTINGS SCREEN	38](#part-13:-settings-screen)

[13.1 Settings Architecture	38](#13.1-settings-architecture)

[13.2 General Tab	38](#13.2-general-tab)

[13.3 Models Tab	38](#13.3-models-tab)

[13.4 API Keys Tab	38](#13.4-api-keys-tab)

[13.5 Types Tab (Instance Types)	38](#13.5-types-tab-\(instance-types\))

[13.6 Cascade Tab	39](#13.6-cascade-tab)

[13.7 Learned Rules Tab	39](#13.7-learned-rules-tab)

[PART 14: CIPHER ORCHESTRATION ENGINE	39](#part-14:-cipher-orchestration-engine)

[14.1 Core Responsibilities	39](#14.1-core-responsibilities)

[14.2 Implementation (Supabase Edge Functions)	39](#14.2-implementation-\(supabase-edge-functions\))

[14.3 Routing Algorithm	39](#14.3-routing-algorithm)

[14.4 Context Window Assembly	40](#14.4-context-window-assembly)

[PART 15: COGNIGRAPH MEMORY SYSTEM	40](#part-15:-cognigraph-memory-system)

[15.1 Data Model	40](#15.1-data-model)

[15.2 Memory Lifecycle	40](#15.2-memory-lifecycle)

[15.3 Memory Types	40](#15.3-memory-types)

[15.4 Retrieval	41](#15.4-retrieval)

[PART 16: MULTI-MODEL ROUTING	41](#part-16:-multi-model-routing)

[16.1 Model Configuration	41](#16.1-model-configuration)

[16.2 Role-Based Assignment	41](#16.2-role-based-assignment)

[16.3 Streaming & Error Handling	41](#16.3-streaming-&-error-handling)

[PART 17: REAL-TIME & COLLABORATION	42](#part-17:-real-time-&-collaboration)

[17.1 Supabase Realtime Channels	42](#17.1-supabase-realtime-channels)

[17.2 Typing Indicators	42](#17.2-typing-indicators)

[17.3 Presence (Future — Multi-User)	42](#17.3-presence-\(future-—-multi-user\))

[PART 18: SUPABASE DATABASE SCHEMA (Complete)	42](#part-18:-supabase-database-schema-\(complete\))

[18.1 Core Tables Summary	42](#18.1-core-tables-summary)

[18.2 RLS Policies	42](#18.2-rls-policies)

[18.3 Storage Buckets	43](#18.3-storage-buckets)

[PART 19: API ROUTES & EDGE FUNCTIONS	43](#part-19:-api-routes-&-edge-functions)

[19.1 REST API (Supabase Auto-Generated)	43](#19.1-rest-api-\(supabase-auto-generated\))

[19.2 Edge Functions	43](#19.2-edge-functions)

[19.3 Webhook Endpoints (Future)	43](#19.3-webhook-endpoints-\(future\))

[PART 20: FRONTEND COMPONENT ARCHITECTURE	44](#part-20:-frontend-component-architecture)

[20.1 Page Structure (Next.js App Router)	44](#20.1-page-structure-\(next.js-app-router\))

[20.2 Shared Components	44](#20.2-shared-components)

[20.3 State Management	44](#20.3-state-management)

[PART 21: EMPTY STATES & ERROR HANDLING	44](#part-21:-empty-states-&-error-handling)

[21.1 Empty States (Every Screen)	44](#21.1-empty-states-\(every-screen\))

[21.2 Error States	45](#21.2-error-states)

[21.3 Loading States	45](#21.3-loading-states)

[PART 22: TESTING & QUALITY ASSURANCE	45](#part-22:-testing-&-quality-assurance)

[22.1 Test Categories	45](#22.1-test-categories)

[22.2 Critical Flows to Test	45](#22.2-critical-flows-to-test)

[PART 23: DEPLOYMENT & INFRASTRUCTURE	46](#part-23:-deployment-&-infrastructure)

[23.1 Environments	46](#23.1-environments)

[23.2 CI/CD	46](#23.2-ci/cd)

[23.3 Monitoring	46](#23.3-monitoring)

[PART 24: PHASED BUILD PLAN	46](#part-24:-phased-build-plan)

[Phase 1: Foundation (Weeks 1–3)	46](#phase-1:-foundation-\(weeks-1–3\))

[Phase 2: Chat Core (Weeks 4–6)	46](#phase-2:-chat-core-\(weeks-4–6\))

[Phase 3: Instances & Files (Weeks 7–9)	47](#phase-3:-instances-&-files-\(weeks-7–9\))

[Phase 4: Personas & Memory (Weeks 10–12)	47](#phase-4:-personas-&-memory-\(weeks-10–12\))

[Phase 5: Browser & Teams (Weeks 13–16)	47](#phase-5:-browser-&-teams-\(weeks-13–16\))

[Phase 6: Polish & Analytics (Weeks 17–18)	47](#phase-6:-polish-&-analytics-\(weeks-17–18\))

[APPENDICES	47](#appendices)

[Appendix A: Prototype Component Map	47](#appendix-a:-prototype-component-map)

[Appendix B: Keyboard Shortcuts Reference	47](#appendix-b:-keyboard-shortcuts-reference)

[Appendix C: Tier Feature Matrix	48](#appendix-c:-tier-feature-matrix)

[Appendix D: Database Schema Diagram	48](#appendix-d:-database-schema-diagram)

[Appendix E: Cipher Decision Tree	48](#appendix-e:-cipher-decision-tree)

[Appendix F: Browser Proxy Architecture Diagram	48](#appendix-f:-browser-proxy-architecture-diagram)

[PART 1: PRODUCT FOUNDATION	48](#part-1:-product-foundation-1)

[1.1 Product Vision & Definition	48](#1.1-product-vision-&-definition-1)

[One-Sentence Definition	48](#one-sentence-definition)

[The Three Problems Being Solved	48](#the-three-problems-being-solved)

[What Makes aiConnected Different	49](#what-makes-aiconnected-different)

[Target Users	50](#target-users)

[1.2 Architecture Overview (Four Layers)	50](#1.2-architecture-overview-\(four-layers\)-1)

[Layer 1: Cipher (Invisible Orchestration Engine)	50](#layer-1:-cipher-\(invisible-orchestration-engine\))

[Layer 2: CogniGraph (Structured Knowledge Graph Memory)	51](#layer-2:-cognigraph-\(structured-knowledge-graph-memory\))

[Layer 3: Personas (Bounded AI Collaborators)	52](#layer-3:-personas-\(bounded-ai-collaborators\))

[Layer 4: Fluid UI (Adaptive Interface)	52](#layer-4:-fluid-ui-\(adaptive-interface\))

[Layer Communication Diagram	53](#layer-communication-diagram)

[1.3 Technology Stack	54](#1.3-technology-stack-1)

[Frontend	54](#frontend)

[Backend (Supabase)	55](#backend-\(supabase\))

[AI & Model Access	56](#ai-&-model-access)

[Deployment	56](#deployment)

[Development Tools	56](#development-tools)

[Package Installation Commands	57](#package-installation-commands)

[1.4 Design System & Theming	57](#1.4-design-system-&-theming-1)

[Brand Identity	57](#brand-identity)

[Color Tokens	58](#color-tokens)

[Light Theme	58](#light-theme)

[Dark Theme	59](#dark-theme)

[Sidebar Tokens (Always Dark)	59](#sidebar-tokens-\(always-dark\))

[Tailwind Configuration	60](#tailwind-configuration)

[Typography Scale	62](#typography-scale)

[Font Weight Conventions	62](#font-weight-conventions)

[Spacing System	63](#spacing-system)

[Border Radius Conventions	64](#border-radius-conventions)

[Shared Atomic Components	64](#shared-atomic-components)

[StatusDot	64](#statusdot)

[Avatar	64](#avatar)

[MemoryTypeIcon	65](#memorytypeicon)

[Icon Inventory	65](#icon-inventory)

[Responsive Design	66](#responsive-design)

[Animation Conventions	66](#animation-conventions)

[1.5 Project Structure	67](#1.5-project-structure)

[PART 2: AUTHENTICATION & USER MANAGEMENT	70](#part-2:-authentication-&-user-management-1)

[2.1 Authentication	70](#2.1-authentication-1)

[Provider Configuration	70](#provider-configuration)

[Supabase Auth Configuration	71](#supabase-auth-configuration)

[Authentication Flows	72](#authentication-flows)

[Sign Up (Email \+ Password)	72](#sign-up-\(email-+-password\))

[Sign In (Email \+ Password)	72](#sign-in-\(email-+-password\))

[Sign In (OAuth)	73](#sign-in-\(oauth\))

[OAuth Callback Handler	73](#oauth-callback-handler)

[Password Reset	73](#password-reset)

[Sign Out	74](#sign-out)

[Session Management	74](#session-management)

[Auth Middleware	74](#auth-middleware)

[Auth UI Pages	75](#auth-ui-pages)

[Login Page (/auth/login)	75](#login-page-\(/auth/login\))

[Sign Up Page (/auth/signup)	75](#sign-up-page-\(/auth/signup\))

[Password Reset Page (/auth/reset-password)	76](#password-reset-page-\(/auth/reset-password\))

[2.2 User Profile	76](#2.2-user-profile-1)

[Database Schema	76](#database-schema)

[Shared Utility Function	78](#shared-utility-function)

[Preferences JSON Schema	78](#preferences-json-schema)

[Profile Display in UI	79](#profile-display-in-ui)

[2.3 Pricing Tiers & Feature Gating	79](#2.3-pricing-tiers-&-feature-gating-1)

[Tier Definitions	79](#tier-definitions)

[BYOK (Bring Your Own Key)	81](#byok-\(bring-your-own-key\))

[Credits System (Free Tier)	82](#credits-system-\(free-tier\))

[Subscriptions	83](#subscriptions)

[Stripe Integration	84](#stripe-integration)

[Feature Gate Utility	84](#feature-gate-utility)

[Pricing Modal UI	87](#pricing-modal-ui)

[2.4 Onboarding Flow	88](#2.4-onboarding-flow-1)

[Trigger Conditions	88](#trigger-conditions)

[Onboarding Modal Design	88](#onboarding-modal-design)

[Step Sequence	88](#step-sequence)

[Progress Indicators	89](#progress-indicators)

[Navigation	90](#navigation)

[Post-Onboarding State	90](#post-onboarding-state)

[Progressive Feature Discovery	90](#progressive-feature-discovery)

[2.5 Auth-Related Environment Variables	91](#2.5-auth-related-environment-variables)

[PART 3: APPLICATION SHELL & NAVIGATION	91](#part-3:-application-shell-&-navigation-1)

[3.1 Shell Layout	92](#3.1-shell-layout)

[3.2 Sidebar Navigation	93](#3.2-sidebar-navigation)

[Dimensions & Behavior	93](#dimensions-&-behavior)

[Visual Design	94](#visual-design)

[Sidebar Sections (Top to Bottom)	94](#sidebar-sections-\(top-to-bottom\))

[Header (60px)	94](#header-\(60px\))

[Primary Navigation	94](#primary-navigation)

[Divider	95](#divider)

[Advanced Section	95](#advanced-section)

[Settings (Bottom Section)	96](#settings-\(bottom-section\))

[User Profile Chip (Very Bottom)	96](#user-profile-chip-\(very-bottom\))

[Mobile Sidebar Overlay	97](#mobile-sidebar-overlay)

[Auto-Collapse on Browser Mode	97](#auto-collapse-on-browser-mode)

[3.3 Top Bar	97](#3.3-top-bar)

[Dimensions	97](#dimensions)

[Layout	97](#layout)

[Breadcrumbs	98](#breadcrumbs)

[Sidebar Toggle Button	99](#sidebar-toggle-button)

[Right-Side Action Buttons	99](#right-side-action-buttons)

[\+New Button (Always Visible)	100](#+new-button-\(always-visible\))

[Chat Drawer Toggle (Chat Screen Only, Desktop Only)	100](#chat-drawer-toggle-\(chat-screen-only,-desktop-only\))

[Notification Bell (Always Visible)	100](#notification-bell-\(always-visible\))

[Theme Toggle (Always Visible)	100](#theme-toggle-\(always-visible\))

[Right Panel Toggle (Chat Screen Only, Desktop Only)	100](#right-panel-toggle-\(chat-screen-only,-desktop-only\))

[Mobile Top Bar Differences	101](#mobile-top-bar-differences)

[3.4 Command Palette	101](#3.4-command-palette)

[Trigger	101](#trigger)

[Layout	101](#layout-1)

[Structure	101](#structure)

[Filtering Behavior	102](#filtering-behavior)

[Dismiss	102](#dismiss)

[3.5 Notification System	103](#3.5-notification-system)

[Database Schema	103](#database-schema-1)

[Notification Types	103](#notification-types)

[Notification Dropdown	104](#notification-dropdown)

[Notification History Modal	105](#notification-history-modal)

["Mark All Read" Action	105](#"mark-all-read"-action)

[3.6 Keyboard Shortcuts	106](#3.6-keyboard-shortcuts)

[Global Shortcut Map	106](#global-shortcut-map)

[Implementation	106](#implementation)

[Shortcuts Modal	107](#shortcuts-modal)

[3.7 Export Modal	108](#3.7-export-modal)

[PART 4: HOME / DASHBOARD SCREEN	110](#part-4:-home-/-dashboard-screen-1)

[4.1 Layout	110](#4.1-layout-1)

[Container	110](#container)

[Content Flow	110](#content-flow)

[4.2 Greeting Section	111](#4.2-greeting-section)

[Container	111](#container-1)

[Greeting Text	111](#greeting-text)

[Subtitle	112](#subtitle)

[4.3 Quick Stats Grid	112](#4.3-quick-stats-grid)

[Grid Container	112](#grid-container)

[Stat Cards	113](#stat-cards)

[Data Source Queries	114](#data-source-queries)

[Data Loading Hook	115](#data-loading-hook)

[Loading State	116](#loading-state)

[Error State	116](#error-state)

[4.4 Activity Log Data Model	117](#4.4-activity-log-data-model)

[Schema	117](#schema)

[Activity Log Population	118](#activity-log-population)

[metadata JSONB Examples	120](#metadata-jsonb-examples)

[4.5 Recent Activity Feed	121](#4.5-recent-activity-feed)

[Section Header	121](#section-header)

[Activity List Container	121](#activity-list-container)

[Activity Item	121](#activity-item)

[Click Navigation	123](#click-navigation)

[Data Query	125](#data-query)

[Loading State	125](#loading-state-1)

[Empty Activity State	126](#empty-activity-state)

[Real-Time Updates	126](#real-time-updates)

[4.6 Empty State (First-Time User)	127](#4.6-empty-state-\(first-time-user\))

[Detection	127](#detection)

[Layout	127](#layout-2)

[Card Container	128](#card-container)

[Card 1: "Create your first Instance"	128](#card-1:-"create-your-first-instance")

[Card 2: "Meet your first Persona"	128](#card-2:-"meet-your-first-persona")

[Card 3: "Start a conversation"	129](#card-3:-"start-a-conversation")

[Onboarding Replay Link	129](#onboarding-replay-link)

[4.7 Partial Empty States	129](#4.7-partial-empty-states)

[Stat Card with Zero Count	129](#stat-card-with-zero-count)

[Supplemental Hint	130](#supplemental-hint)

[4.8 Dashboard Data Freshness	130](#4.8-dashboard-data-freshness)

[Polling Strategy	130](#polling-strategy)

[Cache Invalidation	131](#cache-invalidation)

[PART 5: CHAT SYSTEM (Core)	132](#part-5:-chat-system-\(core\)-1)

[5.1 Data Model	132](#5.1-data-model-1)

[Chats Table	132](#chats-table)

[Chat Participants Table	133](#chat-participants-table)

[Messages Table	134](#messages-table)

[Content Blocks Schema	135](#content-blocks-schema)

[Soft Delete & Recently Deleted	136](#soft-delete-&-recently-deleted)

[TypeScript Types	136](#typescript-types)

[5.2 Chat List Drawer (Left Panel)	137](#5.2-chat-list-drawer-\(left-panel\)-1)

[Dimensions & Behavior	137](#dimensions-&-behavior-1)

[Header	138](#header)

[Multi-Select Mode	138](#multi-select-mode)

[Search Bar	139](#search-bar)

[Chat List (Grouped by Instance)	139](#chat-list-\(grouped-by-instance\))

[Recently Deleted Section	141](#recently-deleted-section)

[5.3 Message List	142](#5.3-message-list-1)

[Container	143](#container-2)

[Banner Area (Above Messages)	143](#banner-area-\(above-messages\))

[Auto-Rename Banner	143](#auto-rename-banner)

[Suggested Move Banner	144](#suggested-move-banner)

[Message Rendering	144](#message-rendering)

[Cipher Routing Note (Optional)	145](#cipher-routing-note-\(optional\))

[Message Header Row	145](#message-header-row)

[Hover Action Bar	146](#hover-action-bar)

[Message Content	146](#message-content)

[Memory Extraction Indicator (Optional)	147](#memory-extraction-indicator-\(optional\))

[Inline Interactive Components (Content Blocks)	147](#inline-interactive-components-\(content-blocks\))

[Regenerate Button	148](#regenerate-button)

[Empty State	148](#empty-state)

[Message Loading	149](#message-loading)

[5.4 Message Composer	150](#5.4-message-composer-1)

[Container	150](#container-3)

[Left Side: Persona Selector (@mention)	150](#left-side:-persona-selector-\(@mention\))

[Left Side: Model Selector	151](#left-side:-model-selector)

[Center: Text Input	153](#center:-text-input)

[Right Side: Action Buttons	153](#right-side:-action-buttons)

[Draft Indicator	153](#draft-indicator)

[Send Message Flow	154](#send-message-flow)

[5.5 Auto-Rename & Suggested Move	155](#5.5-auto-rename-&-suggested-move)

[Auto-Rename	155](#auto-rename)

[Suggested Move	156](#suggested-move)

[5.6 Right Panel	156](#5.6-right-panel)

[Tab Navigation	156](#tab-navigation)

[Nav Tab: Chat Navigator	157](#nav-tab:-chat-navigator)

[Filters Tab	158](#filters-tab)

[Pinned Tab	160](#pinned-tab)

[People Tab	161](#people-tab)

[5.7 Export System	162](#5.7-export-system-1)

[Export Edge Function	163](#export-edge-function)

[Export History	164](#export-history)

[5.8 AI Response Pipeline	164](#5.8-ai-response-pipeline-1)

[Flow Diagram	164](#flow-diagram)

[Step 2: Cipher Routing	166](#step-2:-cipher-routing)

[Step 3: Context Assembly	166](#step-3:-context-assembly)

[Step 4: Model Call	167](#step-4:-model-call)

[Step 5: Save Response	168](#step-5:-save-response)

[Step 6: Post-Response Tasks	168](#step-6:-post-response-tasks)

[Error Handling & Retry	169](#error-handling-&-retry)

[PART 6: SEARCH SCREEN	171](#part-6:-search-screen-1)

[6.1 Layout	171](#6.1-layout-1)

[Container	171](#container-4)

[Content Flow	171](#content-flow-1)

[6.2 Search Input	172](#6.2-search-input)

[Input Container	172](#input-container)

[Input Elements	172](#input-elements)

[Input Behavior	174](#input-behavior)

[Instance Scope Indicator	174](#instance-scope-indicator)

[6.3 Filter Chips	175](#6.3-filter-chips)

[Container	176](#container-5)

[Filter Definitions	176](#filter-definitions)

[Chip Styling	176](#chip-styling)

[Result Count Badges	177](#result-count-badges)

[6.4 Search Execution	178](#6.4-search-execution)

[State Management	178](#state-management)

[Search Request/Response Types	178](#search-request/response-types)

[React Query Hook	179](#react-query-hook)

[6.5 Edge Function: Unified Search	180](#6.5-edge-function:-unified-search)

[Function Entry Point	180](#function-entry-point)

[Backend 1: Chat Search	182](#backend-1:-chat-search)

[Backend 2: File Search	186](#backend-2:-file-search)

[Backend 3: Memory Search (Semantic)	188](#backend-3:-memory-search-\(semantic\))

[Backend 4: Web Search	191](#backend-4:-web-search)

[Backend 5: Internal Search	192](#backend-5:-internal-search)

[Result Merging & Ranking	194](#result-merging-&-ranking)

[Snippet Highlighting Utility	196](#snippet-highlighting-utility)

[6.6 Search Results Display	197](#6.6-search-results-display)

[Results Container	197](#results-container)

[Result Card	197](#result-card)

[Result Card Navigation	200](#result-card-navigation)

[6.7 Loading, Empty, and Error States	201](#6.7-loading,-empty,-and-error-states)

[Loading State	201](#loading-state-2)

[No Results State	202](#no-results-state)

[Error State	202](#error-state-1)

[Partial Backend Failure	203](#partial-backend-failure)

[6.8 Result Action Flows	204](#6.8-result-action-flows)

["Chat with this"	204](#"chat-with-this")

["Send to Instance"	205](#"send-to-instance")

["Save" (Bookmark)	206](#"save"-\(bookmark\))

[6.9 Search History & Saved Searches	207](#6.9-search-history-&-saved-searches)

[Recent Searches Section	207](#recent-searches-section)

[Saved Searches Section	208](#saved-searches-section)

[Empty History State	209](#empty-history-state)

[6.10 Database Schema	210](#6.10-database-schema)

[search\_history Table	210](#search_history-table)

[saved\_results Table	211](#saved_results-table)

[6.11 Tier Gating	211](#6.11-tier-gating)

[6.12 Performance Considerations	213](#6.12-performance-considerations)

[Debouncing	213](#debouncing)

[Backend Timeout	213](#backend-timeout)

[Caching	213](#caching)

[Full-Text Search Index Maintenance	213](#full-text-search-index-maintenance)

[Embedding Generation Cost	214](#embedding-generation-cost)

[PART 7: SPACES / INSTANCES SCREEN	215](#part-7:-spaces-/-instances-screen-1)

[7.1 Data Model	215](#7.1-data-model-1)

[Instances Table	215](#instances-table)

[Instance Personas (Join Table)	216](#instance-personas-\(join-table\))

[Tasks Table	216](#tasks-table)

[Workspace Items Table	217](#workspace-items-table)

[Instance Settings JSON Structure	218](#instance-settings-json-structure)

[Settings Cascade	218](#settings-cascade)

[7.2 Instance List View	219](#7.2-instance-list-view)

[Header	219](#header-1)

[Instance Items	219](#instance-items)

[Data Query	220](#data-query-1)

[Empty State	221](#empty-state-1)

[7.3 Instance Detail View	221](#7.3-instance-detail-view-1)

[Container	221](#container-6)

[Header	222](#header-2)

[Tab Navigation	222](#tab-navigation-1)

[Tab Content Container	223](#tab-content-container)

[7.4 Overview Tab	223](#7.4-overview-tab)

[Open Forum	224](#open-forum)

[Persona Roster	225](#persona-roster)

[Activity	226](#activity)

[7.5 Tasks Tab	226](#7.5-tasks-tab)

[Header Row	226](#header-row)

[Task List (v1)	226](#task-list-\(v1\))

[Task Board (v2 — Kanban)	227](#task-board-\(v2-—-kanban\))

[Quick Add	227](#quick-add)

[7.6 Workspace Tab	228](#7.6-workspace-tab)

[Filter Row	228](#filter-row)

[Workspace Grid	229](#workspace-grid)

[Drag-and-Drop Canvas (v2)	229](#drag-and-drop-canvas-\(v2\))

[7.7 Chats Tab	230](#7.7-chats-tab)

[Header Row	230](#header-row-1)

[Chat List	230](#chat-list)

[7.8 Files Tab	230](#7.8-files-tab)

[Filter Row	231](#filter-row-1)

[File List	231](#file-list)

[Empty State	231](#empty-state-2)

[7.9 Settings Tab	231](#7.9-settings-tab)

[Header	231](#header-3)

[Settings List	231](#settings-list)

[Settings Available for Override	232](#settings-available-for-override)

[Edit Flow	233](#edit-flow)

[7.10 Create Instance Flow	233](#7.10-create-instance-flow)

[Modal Layout	233](#modal-layout)

[Step 1: Name & Type	233](#step-1:-name-&-type)

[Step 2: Optional Config	233](#step-2:-optional-config)

[Action Buttons	233](#action-buttons)

[PART 8: GLOBAL FILES SCREEN	235](#part-8:-global-files-screen-1)

[8.1 Data Model	235](#8.1-data-model-1)

[Files Table	235](#files-table)

[metadata JSONB Structure	236](#metadata-jsonb-structure)

[Storage Configuration	237](#storage-configuration)

[Visibility Levels	238](#visibility-levels)

[TypeScript Interface	238](#typescript-interface)

[8.2 Screen Layout	239](#8.2-screen-layout)

[Container	239](#container-7)

[Content Flow (Top to Bottom)	239](#content-flow-\(top-to-bottom\))

[8.3 Header	239](#8.3-header)

[8.4 Search Bar	241](#8.4-search-bar)

[8.5 Batch Actions Bar	242](#8.5-batch-actions-bar)

[Move Popover	244](#move-popover)

[Visibility Dropdown	245](#visibility-dropdown)

[Delete Confirmation Dialog	246](#delete-confirmation-dialog)

[8.6 Filter Chips	248](#8.6-filter-chips)

[8.7 File List	249](#8.7-file-list)

[Data Query	249](#data-query-2)

[File Row	250](#file-row)

[Checkbox (Select Mode Only)	250](#checkbox-\(select-mode-only\))

[File Type Icon	251](#file-type-icon)

[File Info (flex: 1, minWidth 0\)	252](#file-info-\(flex:-1,-minwidth-0\))

[Action Buttons (Right Side, Non-Select Mode)	255](#action-buttons-\(right-side,-non-select-mode\))

[File Context Menu	255](#file-context-menu)

[Inline Rename	257](#inline-rename)

[Tag Editor Popover	258](#tag-editor-popover)

[8.8 File Upload	259](#8.8-file-upload)

[Upload Trigger Points	259](#upload-trigger-points)

[Upload Function	259](#upload-function)

[File Type Inference	261](#file-type-inference)

[Storage Usage RPC	262](#storage-usage-rpc)

[Size Limits by Tier	263](#size-limits-by-tier)

[Upload Error Handling	263](#upload-error-handling)

[Drag-and-Drop Zone	264](#drag-and-drop-zone)

[Upload Progress	266](#upload-progress)

[8.9 File Preview	267](#8.9-file-preview)

[Preview Modal Container	267](#preview-modal-container)

[Top Bar	267](#top-bar)

[Type-Specific Preview	268](#type-specific-preview)

[Image Preview Detail	269](#image-preview-detail)

[Signed URL Generation	269](#signed-url-generation)

[8.10 AI-Generated Files	270](#8.10-ai-generated-files)

[Generation Flow	270](#generation-flow)

[Implementation	270](#implementation-1)

[8.11 Download	272](#8.11-download)

[Single File Download	272](#single-file-download)

[Batch Download	273](#batch-download)

[8.12 Empty States	274](#8.12-empty-states)

[No Files At All (New User)	274](#no-files-at-all-\(new-user\))

[No Files Match Filter	274](#no-files-match-filter)

[Loading State	274](#loading-state-3)

[8.13 Storage Usage Indicator	275](#8.13-storage-usage-indicator)

[PART 9: PEOPLE / PERSONAS SCREEN	277](#part-9:-people-/-personas-screen-1)

[9.1 Data Model	277](#9.1-data-model-1)

[Personas Table	277](#personas-table)

[Persona Skills Table	278](#persona-skills-table)

[Persona Boundaries Table	279](#persona-boundaries-table)

[Persona Memories Table	279](#persona-memories-table)

[Persona Health Metrics Table	280](#persona-health-metrics-table)

[TypeScript Types	281](#typescript-types-1)

[9.2 Persona List View	283](#9.2-persona-list-view-1)

[Container	283](#container-8)

[Header	283](#header-4)

[Persona Items	283](#persona-items)

[Data Query	284](#data-query-3)

[Tier Limits	284](#tier-limits)

[Persona Templates Section	285](#persona-templates-section)

[Templates Schema	286](#templates-schema)

[9.3 Persona Detail View	287](#9.3-persona-detail-view-1)

[Container	287](#container-9)

[Header	287](#header-5)

[Tab Navigation	288](#tab-navigation-2)

[Tab Content Container	288](#tab-content-container-1)

[9.4 Overview Tab	289](#9.4-overview-tab)

[Quick Stats	289](#quick-stats)

[Deployments Section	290](#deployments-section)

[Skills Summary	290](#skills-summary)

[Recent Conversations	291](#recent-conversations)

[9.5 Identity Tab	292](#9.5-identity-tab)

[Section Header	292](#section-header-1)

[Identity Fields	292](#identity-fields)

[Lock Identity Toggle	294](#lock-identity-toggle)

[Visibility Selector	295](#visibility-selector)

[9.6 Boundaries Tab	295](#9.6-boundaries-tab)

[Section Header	295](#section-header-2)

[Will Do Section	295](#will-do-section)

[Won't Do Section	296](#won't-do-section)

[Escalation Rule	296](#escalation-rule)

[Skill Ceiling Control	297](#skill-ceiling-control)

[Boundary CRUD Operations	298](#boundary-crud-operations)

[9.7 Memory Tab	298](#9.7-memory-tab)

[Header Row	299](#header-row-2)

[Batch Actions Bar	300](#batch-actions-bar)

[Memory Items	300](#memory-items)

[Data Query	302](#data-query-4)

[Memory Edit Flow	303](#memory-edit-flow)

[9.8 Skills Tab	303](#9.8-skills-tab)

[Skill Capacity Bar	303](#skill-capacity-bar)

[Skills List	304](#skills-list)

[Level Indicator (v2)	304](#level-indicator-\(v2\))

[Add Skill	304](#add-skill)

[Skill Ceiling Enforcement	305](#skill-ceiling-enforcement)

[Learning Consent Flow	305](#learning-consent-flow)

[9.9 Health Tab	306](#9.9-health-tab)

[Health Metrics Grid	306](#health-metrics-grid)

[Status Explanations	307](#status-explanations)

[Recovery Actions	308](#recovery-actions)

[Health History (v2)	309](#health-history-\(v2\))

[9.10 Create Persona Modal	309](#9.10-create-persona-modal)

[Modal Container	309](#modal-container)

[Header	309](#header-6)

[Step Indicator	310](#step-indicator)

[Step 0: Name & Role	310](#step-0:-name-&-role)

[Step 1: Purpose	311](#step-1:-purpose)

[Step 2: Boundaries	311](#step-2:-boundaries)

[Step 3: Review	311](#step-3:-review)

[Navigation Buttons	312](#navigation-buttons)

[Validation	313](#validation)

[Create Submission	313](#create-submission)

[Template Pre-fill	314](#template-pre-fill)

[9.11 Persona Status Lifecycle	315](#9.11-persona-status-lifecycle)

[System Prompt Injection	316](#system-prompt-injection)

[PART 10: TEAMS SCREEN (Agentic Teams)	316](#part-10:-teams-screen-\(agentic-teams\)-1)

[10.1 Conceptual Architecture	317](#10.1-conceptual-architecture)

[Team Types	317](#team-types)

[Three-Tier Hierarchy	317](#three-tier-hierarchy)

[10.2 Data Model	318](#10.2-data-model)

[Teams Table	318](#teams-table)

[Team Members Table	319](#team-members-table)

[Team Tasks Table	320](#team-tasks-table)

[Team Runs Table	321](#team-runs-table)

[Plan JSON Structure	322](#plan-json-structure)

[Schedule JSON Structure	323](#schedule-json-structure)

[TypeScript Interfaces	323](#typescript-interfaces)

[10.3 Teams List View	325](#10.3-teams-list-view)

[Container	325](#container-10)

[Header	326](#header-7)

[Filter Chips	327](#filter-chips)

[Team Cards	328](#team-cards)

[Data Query	332](#data-query-5)

[Empty State	332](#empty-state-3)

[Loading State	333](#loading-state-4)

[10.4 Executive Teams Teaser	333](#10.4-executive-teams-teaser)

[10.5 Team Detail View	335](#10.5-team-detail-view)

[Container	335](#container-11)

[Header	335](#header-8)

[Tab Navigation	337](#tab-navigation-3)

[Tab Content Container	338](#tab-content-container-2)

[10.6 Overview Tab	339](#10.6-overview-tab)

[Quick Stats	339](#quick-stats-1)

[Current Run Status Card	339](#current-run-status-card)

[Goal & Success Criteria	341](#goal-&-success-criteria)

[Recent Activity	342](#recent-activity)

[10.7 Plan Tab	343](#10.7-plan-tab)

[Goal Section	343](#goal-section)

[Steps List	343](#steps-list)

[Add Step Inline Form	345](#add-step-inline-form)

[Auto-Generate Plan	345](#auto-generate-plan)

[Empty Plan State	346](#empty-plan-state)

[10.8 Members Tab	346](#10.8-members-tab)

[Hierarchy Visualization	346](#hierarchy-visualization)

[Reporting Lines Visual	348](#reporting-lines-visual)

[Add Member	348](#add-member)

[Member Limits	350](#member-limits)

[10.9 History Tab	350](#10.9-history-tab)

[Run List	350](#run-list)

[Expanded Run Log	351](#expanded-run-log)

[Empty History	352](#empty-history)

[10.10 Create Team Modal	353](#10.10-create-team-modal)

[Modal Container	353](#modal-container-1)

[Header	353](#header-9)

[Step Indicator	354](#step-indicator-1)

[Step 0: Basics	354](#step-0:-basics)

[Step 1: Members	355](#step-1:-members)

[Step 2: Plan (Optional)	357](#step-2:-plan-\(optional\))

[Step 3: Schedule (Recurring Only)	358](#step-3:-schedule-\(recurring-only\))

[Step 4: Review	358](#step-4:-review)

[Navigation Buttons	360](#navigation-buttons-1)

[Validation	360](#validation-1)

[Create Submission	361](#create-submission-1)

[10.11 Team Status Lifecycle	364](#10.11-team-status-lifecycle)

[State Machine	364](#state-machine)

[10.12 Team Execution Engine	365](#10.12-team-execution-engine)

[Trigger	365](#trigger-1)

[Execution Flow	366](#execution-flow)

[Context Assembly for Team Tasks	367](#context-assembly-for-team-tasks)

[Error Handling	369](#error-handling)

[Progress Tracking	370](#progress-tracking)

[Recurring Execution Scheduler	370](#recurring-execution-scheduler)

[10.13 Tier Gating	371](#10.13-tier-gating)

[Non-Pro Gated Screen	372](#non-pro-gated-screen)

[PART 11: BROWSER / CO-BROWSING WORKSPACE	372](#part-11:-browser-/-co-browsing-workspace-1)

[11.1 Architecture Overview	373](#11.1-architecture-overview-1)

[Implementation Strategy	373](#implementation-strategy)

[Proxy Architecture	374](#proxy-architecture)

[Edge Function: browser-proxy	375](#edge-function:-browser-proxy)

[Fallback Error View	378](#fallback-error-view)

[11.2 Data Model	379](#11.2-data-model-1)

[Browser Sessions Table	379](#browser-sessions-table)

[Browser Tabs Table	379](#browser-tabs-table)

[Browser History Table	380](#browser-history-table)

[Browser Extracts Table	381](#browser-extracts-table)

[Browser Highlights Table	382](#browser-highlights-table)

[TypeScript Interfaces	383](#typescript-interfaces-1)

[11.3 Session Manager Screen (Non-Browse Mode)	385](#11.3-session-manager-screen-\(non-browse-mode\)-1)

[Container	385](#container-12)

[Header	385](#header-10)

[Sub-Tab Navigation	386](#sub-tab-navigation)

[Active Sessions Sub-Tab	387](#active-sessions-sub-tab)

[History Sub-Tab	388](#history-sub-tab)

[Saved Extracts Sub-Tab	390](#saved-extracts-sub-tab)

[Empty States	391](#empty-states)

[11.4 Co-Browse Layout (Browse Mode)	391](#11.4-co-browse-layout-\(browse-mode\))

[State Variables	392](#state-variables)

[Container	392](#container-13)

[View Mode Layout Matrix	392](#view-mode-layout-matrix)

[11.5 Web Viewport	393](#11.5-web-viewport)

[Container	393](#container-14)

[Tab Strip (Float/Icon Modes)	394](#tab-strip-\(float/icon-modes\))

[Tab List Popup	396](#tab-list-popup)

[Tab State Management	397](#tab-state-management)

[Iframe Rendering	399](#iframe-rendering)

[Loading State	400](#loading-state-5)

[11.6 Floating Navigation Bar	401](#11.6-floating-navigation-bar)

[Container	401](#container-15)

[Controls (Left to Right)	401](#controls-\(left-to-right\))

[Auto-Hide Behavior	403](#auto-hide-behavior)

[Minimized Nav Dot	404](#minimized-nav-dot)

[11.7 View Switcher Menu	405](#11.7-view-switcher-menu-1)

[From Floating Nav Bar	405](#from-floating-nav-bar)

[From Chat Panel Header	405](#from-chat-panel-header)

[Section Header	406](#section-header-3)

[5 View Options	406](#5-view-options)

[Divider	407](#divider-1)

[Additional Actions (Below Divider)	407](#additional-actions-\(below-divider\))

[11.8 Chat View Modes — Complete Specifications	407](#11.8-chat-view-modes-—-complete-specifications)

[11.8.1 Float Bar (Default for first-time users)	407](#11.8.1-float-bar-\(default-for-first-time-users\))

[11.8.2 Icon Only	409](#11.8.2-icon-only)

[11.8.3 Sidebar (320px)	410](#11.8.3-sidebar-\(320px\))

[11.8.4 50/50 Split	410](#11.8.4-50/50-split)

[11.8.5 Chat Only	411](#11.8.5-chat-only)

[11.9 Chat Panel (Sidebar / Split / ChatOnly)	411](#11.9-chat-panel-\(sidebar-/-split-/-chatonly\))

[Tab Strip (Inside Chat Panel)	411](#tab-strip-\(inside-chat-panel\))

[Persona Header	412](#persona-header)

[Chat Messages	413](#chat-messages)

[Composer	414](#composer)

[Quick Action Chips	415](#quick-action-chips)

[11.10 Page Interaction Layer	416](#11.10-page-interaction-layer)

[Overlay Script (browser-overlay.js)	416](#overlay-script-\(browser-overlay.js\))

[Highlight Tool Flow	422](#highlight-tool-flow)

[Extract to Instance Flow	423](#extract-to-instance-flow)

[11.11 Page Awareness System	425](#11.11-page-awareness-system)

[Badge	425](#badge)

[Context Injection	426](#context-injection)

[11.12 Session Lifecycle	427](#11.12-session-lifecycle)

[Creating a Session	427](#creating-a-session)

[Resuming a Session	428](#resuming-a-session)

[Closing a Session	429](#closing-a-session)

[11.13 Security & Limitations	430](#11.13-security-&-limitations)

[Proxy Security	430](#proxy-security)

[Known Limitations (v1 — Proxy Mode)	431](#known-limitations-\(v1-—-proxy-mode\))

[Desktop Version Advantages (v2)	431](#desktop-version-advantages-\(v2\))

[11.14 Tier Gating	432](#11.14-tier-gating)

[Free-Tier Gated Screen	432](#free-tier-gated-screen)

[PART 12: INSIGHTS & ANALYTICS SCREEN	434](#part-12:-insights-&-analytics-screen-1)

[12.1 Data Architecture	434](#12.1-data-architecture)

[No Dedicated Analytics Tables (v1)	434](#no-dedicated-analytics-tables-\(v1\))

[TypeScript Response Interfaces	435](#typescript-response-interfaces)

[Analytics Edge Function	437](#analytics-edge-function)

[v2: Materialized Views	439](#v2:-materialized-views)

[12.2 Edge Function: computeOverview	439](#12.2-edge-function:-computeoverview)

[Card 1: Total Conversations	441](#card-1:-total-conversations)

[Card 2: Persona Utilization	442](#card-2:-persona-utilization)

[Card 3: Memories Created	443](#card-3:-memories-created)

[Card 4: Top Model	443](#card-4:-top-model)

[Card 5: Most Active Persona	444](#card-5:-most-active-persona)

[Card 6: Avg Conversation Length	444](#card-6:-avg-conversation-length)

[Card 7: Tokens This Month	445](#card-7:-tokens-this-month)

[Card 8: Searches Performed	446](#card-8:-searches-performed)

[Daily Activity Chart Data	446](#daily-activity-chart-data)

[Storage Usage Summary	447](#storage-usage-summary)

[12.3 Screen Layout	448](#12.3-screen-layout)

[Container	448](#container-16)

[Header	448](#header-11)

[Tab Navigation \+ Controls Row	448](#tab-navigation-+-controls-row)

[Last Updated Indicator	451](#last-updated-indicator)

[12.4 Overview Tab	451](#12.4-overview-tab)

[Grid Layout	451](#grid-layout)

[Metric Card Component	452](#metric-card-component)

[8 Overview Metrics Summary	453](#8-overview-metrics-summary)

[Free-Tier Card Visibility	454](#free-tier-card-visibility)

[Activity Trend Chart (Plus and Above)	454](#activity-trend-chart-\(plus-and-above\))

[Storage Usage Bar (All Tiers)	457](#storage-usage-bar-\(all-tiers\))

[Data Fetching	457](#data-fetching)

[Loading State	458](#loading-state-6)

[12.5 Persona Performance Tab	458](#12.5-persona-performance-tab)

[Edge Function: computePersonaPerformance	458](#edge-function:-computepersonaperformance)

[Supporting RPC Functions	459](#supporting-rpc-functions)

[Persona Cards	460](#persona-cards)

[Sort Controls	461](#sort-controls)

[Click Behavior	462](#click-behavior)

[Loading State	462](#loading-state-7)

[Empty State	462](#empty-state-4)

[12.6 Model Usage Tab	463](#12.6-model-usage-tab)

[Edge Function: computeModelUsage	463](#edge-function:-computemodelusage)

[Provider Inference & Cost Estimation	464](#provider-inference-&-cost-estimation)

[Model Row Layout	465](#model-row-layout)

[Total Summary Row	465](#total-summary-row)

[BYOK Indicator	465](#byok-indicator)

[Cost Disclaimer	466](#cost-disclaimer)

[Model Color Coding (v2)	466](#model-color-coding-\(v2\))

[Empty State	467](#empty-state-5)

[12.7 Memory Health Tab	467](#12.7-memory-health-tab)

[Edge Function: computeMemoryHealth	467](#edge-function:-computememoryhealth)

[Memory Stats Grid	468](#memory-stats-grid)

[Flagged Card Treatment	469](#flagged-card-treatment)

[Flagged Review Modal	469](#flagged-review-modal)

[Flagged Memory Logic	470](#flagged-memory-logic)

[Flagged Data Query	470](#flagged-data-query)

[Empty State	470](#empty-state-6)

[12.8 Export Analytics (Premium+)	471](#12.8-export-analytics-\(premium+\))

[Export Dropdown	471](#export-dropdown)

[Implementation	471](#implementation-2)

[CSV Format Per Tab	472](#csv-format-per-tab)

[12.9 Empty States	472](#12.9-empty-states)

[No Data (New User)	472](#no-data-\(new-user\))

[Partial Data	472](#partial-data)

[12.10 Tier Feature Matrix	473](#12.10-tier-feature-matrix)

[Gated Tab Content	473](#gated-tab-content)

[12.11 Refresh & Caching	474](#12.11-refresh-&-caching)

[React Query Configuration	474](#react-query-configuration)

[Error State	474](#error-state-2)

[12.12 Accessibility & Responsive	474](#12.12-accessibility-&-responsive)

[Responsive Behavior	474](#responsive-behavior)

[Keyboard Navigation	475](#keyboard-navigation)

[PART 13: SETTINGS SCREEN	476](#part-13:-settings-screen-1)

[13.1 Data Architecture	476](#13.1-data-architecture)

[New Table: model\_role\_assignments	476](#new-table:-model_role_assignments)

[New Table: instance\_types	478](#new-table:-instance_types)

[New Table: instruction\_memory	481](#new-table:-instruction_memory)

[Existing Tables Referenced	483](#existing-tables-referenced)

[Global Instance Settings	483](#global-instance-settings)

[13.2 Screen Layout	484](#13.2-screen-layout)

[Container	484](#container-17)

[Header	484](#header-12)

[Tab Bar	485](#tab-bar)

[Tab Content Area	486](#tab-content-area)

[13.3 General Tab	486](#13.3-general-tab)

[Setting Row Component	486](#setting-row-component)

[Row 1: Interface Mode	487](#row-1:-interface-mode)

[Row 2: Account	488](#row-2:-account)

[Row 3: Billing	491](#row-3:-billing)

[Row 4: Voice & Tone	493](#row-4:-voice-&-tone)

[Row 5: Storage	495](#row-5:-storage)

[Row 6: Integrations	497](#row-6:-integrations)

[System Intelligence Section	499](#system-intelligence-section)

[Onboarding Replay	501](#onboarding-replay)

[Chat Cleanup & Organization Section	501](#chat-cleanup-&-organization-section)

[Danger Zone	503](#danger-zone)

[13.4 Models Tab	504](#13.4-models-tab)

[Tab Header	504](#tab-header)

[Available Models Registry	504](#available-models-registry)

[Role Assignment Rows	506](#role-assignment-rows)

[Model Selection Popover	508](#model-selection-popover)

[Add Custom Role Button	510](#add-custom-role-button)

[Data Fetching	511](#data-fetching-1)

[13.5 API Keys Tab	512](#13.5-api-keys-tab)

[Tab Header	512](#tab-header-1)

[Key List	512](#key-list)

[Add API Key Flow	515](#add-api-key-flow)

[Data Fetching	520](#data-fetching-2)

[Empty State	521](#empty-state-7)

[13.6 Types Tab (Premium+)	521](#13.6-types-tab-\(premium+\))

[Tier Gate	521](#tier-gate)

[Tab Header	521](#tab-header-2)

[Type List	521](#type-list)

[Instance Count Query	522](#instance-count-query)

[Edit Type Defaults Modal	523](#edit-type-defaults-modal)

[Create New Type	524](#create-new-type)

[Delete Type	525](#delete-type)

[13.7 Cascade Tab (Premium+)	525](#13.7-cascade-tab-\(premium+\))

[Tier Gate	525](#tier-gate-1)

[Tab Header	525](#tab-header-3)

[Cascade Layers	525](#cascade-layers)

[4 Cascade Layers	527](#4-cascade-layers)

[Resolved Settings Preview	529](#resolved-settings-preview)

[13.8 Learned Rules Tab (Plus+)	531](#13.8-learned-rules-tab-\(plus+\))

[Tier Gate	531](#tier-gate-2)

[Tab Header	531](#tab-header-4)

[Filter Pills	531](#filter-pills)

[Rule Count	532](#rule-count)

[Rule List	532](#rule-list)

[Edit Rule Inline	534](#edit-rule-inline)

[Delete Rule	535](#delete-rule)

[Add Rule Manually	535](#add-rule-manually)

[How Cipher Creates Rules (Reference)	536](#how-cipher-creates-rules-\(reference\))

[Data Fetching	537](#data-fetching-3)

[Empty State	537](#empty-state-8)

[13.9 Responsive Behavior	537](#13.9-responsive-behavior)

[Mobile Layout	537](#mobile-layout)

[Tab bar scroll indicator	538](#tab-bar-scroll-indicator)

[13.10 Settings Persistence & Propagation	538](#13.10-settings-persistence-&-propagation)

[Immediate Save Pattern	538](#immediate-save-pattern)

[Cross-Screen Propagation	538](#cross-screen-propagation)

[Optimistic Update Example	539](#optimistic-update-example)

[13.11 Keyboard Accessibility	540](#13.11-keyboard-accessibility)

[PART 14: CIPHER ORCHESTRATION ENGINE	541](#part-14:-cipher-orchestration-engine-1)

[14.1 Architecture Overview	541](#14.1-architecture-overview)

[Core Responsibilities	541](#core-responsibilities)

[Edge Function Architecture	542](#edge-function-architecture)

[Invocation Patterns	544](#invocation-patterns)

[14.2 cipher-route: Message Processing Pipeline	544](#14.2-cipher-route:-message-processing-pipeline)

[Entry Point	544](#entry-point)

[Step 2: Rate Limiting	547](#step-2:-rate-limiting)

[Step 3: Parallel Data Loading	549](#step-3:-parallel-data-loading)

[Step 4: Insert User Message	551](#step-4:-insert-user-message)

[14.3 Routing Algorithm	552](#14.3-routing-algorithm-1)

[Priority Cascade	552](#priority-cascade)

[Skill Matching	554](#skill-matching)

[Intent Classification	556](#intent-classification)

[Skill Relevance Scoring	559](#skill-relevance-scoring)

[Conversation Context Inference	560](#conversation-context-inference)

[Routing Note Format	561](#routing-note-format)

[14.4 Skill Validation	562](#14.4-skill-validation)

[14.5 Model Selection	564](#14.5-model-selection)

[Fallback Chain	565](#fallback-chain)

[API Key Resolution	566](#api-key-resolution)

[14.6 Context Window Assembly	567](#14.6-context-window-assembly)

[Token Budget Allocation	567](#token-budget-allocation)

[Assembly Function	568](#assembly-function)

[Persona Identity Builder	570](#persona-identity-builder)

[Persona Boundaries Builder	571](#persona-boundaries-builder)

[Memory Retrieval	571](#memory-retrieval)

[Instruction Rules Loading	574](#instruction-rules-loading)

[Instance Context & Conversation History Builders	575](#instance-context-&-conversation-history-builders)

[Token Estimation	576](#token-estimation)

[14.7 Streaming Response	576](#14.7-streaming-response)

[Content Block Detection	579](#content-block-detection)

[14.8 cipher-memory: Post-Response Processing	580](#14.8-cipher-memory:-post-response-processing)

[Dispatch	580](#dispatch)

[Memory Extraction	581](#memory-extraction)

[Memory Conflict Detection	583](#memory-conflict-detection)

[Instruction Pattern Detection	584](#instruction-pattern-detection)

[Auto-Rename & Suggested Move	586](#auto-rename-&-suggested-move)

[Activity Logging	590](#activity-logging)

[14.9 cipher-cleanup: Background Maintenance	590](#14.9-cipher-cleanup:-background-maintenance)

[Scheduling	590](#scheduling)

[Implementation	591](#implementation-3)

[Task 1: Stale Memory Flagging	592](#task-1:-stale-memory-flagging)

[Task 2: Weak Memory Decay	592](#task-2:-weak-memory-decay)

[Task 3: Temporary Skill Expiration	593](#task-3:-temporary-skill-expiration)

[Task 4: Soft-Delete Purge	593](#task-4:-soft-delete-purge)

[Task 5: Old Unnamed Chat Flagging	594](#task-5:-old-unnamed-chat-flagging)

[14.10 cipher-health: Daily Health Monitoring	595](#14.10-cipher-health:-daily-health-monitoring)

[Scheduling	595](#scheduling-1)

[Implementation	595](#implementation-4)

[Memory Stability RPC	596](#memory-stability-rpc)

[Drift & Mood Computation	597](#drift-&-mood-computation)

[14.11 Multi-Persona Conversation Coordination	599](#14.11-multi-persona-conversation-coordination)

[Turn-Taking Rules	599](#turn-taking-rules)

[Handoff Detection	599](#handoff-detection)

[14.12 Error Handling & Resilience	600](#14.12-error-handling-&-resilience)

[Error Classification	600](#error-classification)

[Retry Configuration	600](#retry-configuration)

[14.13 Security Considerations	601](#14.13-security-considerations)

[API Key Isolation	601](#api-key-isolation)

[Input Sanitization	601](#input-sanitization)

[Rate Limiting Defense	602](#rate-limiting-defense)

[14.14 Observability	602](#14.14-observability)

[Structured Logging	602](#structured-logging)

[Key Metrics	602](#key-metrics)

[PART 15: COGNIGRAPH MEMORY SYSTEM	604](#part-15:-cognigraph-memory-system-1)

[15.1 Architecture Overview	604](#15.1-architecture-overview)

[15.2 Schema Extensions	606](#15.2-schema-extensions)

[Extending persona\_memories	606](#extending-persona_memories)

[Adding "procedure" Memory Type	607](#adding-"procedure"-memory-type)

[New Table: memory\_edges	608](#new-table:-memory_edges)

[New Table: memory\_checkpoints	609](#new-table:-memory_checkpoints)

[15.3 Memory Types	611](#15.3-memory-types-1)

[Decision	611](#decision)

[Fact	611](#fact)

[Preference	612](#preference)

[Skill	612](#skill)

[Procedure	613](#procedure)

[Updated Extraction Prompt	614](#updated-extraction-prompt)

[15.4 Memory Lifecycle	615](#15.4-memory-lifecycle)

[Stage 1: Extraction	615](#stage-1:-extraction)

[Stage 2: Open Layer (Transient)	616](#stage-2:-open-layer-\(transient\))

[Stage 3: Closed Layer (Permanent)	619](#stage-3:-closed-layer-\(permanent\))

[Stage 4: Deactivation and Deletion	619](#stage-4:-deactivation-and-deletion)

[Stage 5: Expiration	620](#stage-5:-expiration)

[15.5 Memory Edges	621](#15.5-memory-edges)

[Relationship Types	621](#relationship-types)

[Edge Creation	622](#edge-creation)

[Edge Strength Dynamics	623](#edge-strength-dynamics)

[Edge Traversal for Retrieval	624](#edge-traversal-for-retrieval)

[15.6 Conflict Detection and Resolution	626](#15.6-conflict-detection-and-resolution)

[Detection During Extraction	626](#detection-during-extraction)

[Conflict Notification	629](#conflict-notification)

[Conflict Resolution Actions	629](#conflict-resolution-actions)

[15.7 Memory Checkpoints	632](#15.7-memory-checkpoints)

[Checkpoint Triggers	632](#checkpoint-triggers)

[Checkpoint Creation	633](#checkpoint-creation)

[Trigger Detection	636](#trigger-detection)

[Checkpoint Usage in Context Assembly	636](#checkpoint-usage-in-context-assembly)

[Checkpoint Usage in Chat Navigation	638](#checkpoint-usage-in-chat-navigation)

[15.8 Multi-Scope Retrieval	639](#15.8-multi-scope-retrieval)

[Scope Hierarchy	639](#scope-hierarchy)

[Retrieval Pipeline	639](#retrieval-pipeline)

[Instance-Scoped Memory Search	642](#instance-scoped-memory-search)

[Retrieval Scoring	643](#retrieval-scoring)

[Memory Formatting for Context	644](#memory-formatting-for-context)

[Background Reinforcement	645](#background-reinforcement)

[15.9 Memory Statistics & Analytics Integration	646](#15.9-memory-statistics-&-analytics-integration)

[Statistics RPC	646](#statistics-rpc)

[TypeScript Interface for Stats	648](#typescript-interface-for-stats)

[15.10 Graph Visualization (Future / v2)	648](#15.10-graph-visualization-\(future-/-v2\))

[15.11 Performance Considerations	650](#15.11-performance-considerations)

[Embedding Index Tuning	650](#embedding-index-tuning)

[Retrieval Latency Targets	651](#retrieval-latency-targets)

[Memory Limits	651](#memory-limits)

[PART 16: MULTI-MODEL ROUTING	653](#part-16:-multi-model-routing-1)

[16.1 Model Registry	653](#16.1-model-registry)

[Database Table: model\_registry	653](#database-table:-model_registry)

[Seed Data	655](#seed-data)

[TypeScript Interface	656](#typescript-interface-1)

[Model Availability Resolution	657](#model-availability-resolution)

[Client-Side Model Query	658](#client-side-model-query)

[16.2 Provider Abstraction Layer	659](#16.2-provider-abstraction-layer)

[Routing Strategy	659](#routing-strategy)

[Provider Interface	659](#provider-interface)

[Provider Router	660](#provider-router)

[Provider Configurations	662](#provider-configurations)

[Unified Model Call	663](#unified-model-call)

[Anthropic Direct API Adapter	664](#anthropic-direct-api-adapter)

[Google Direct API Adapter	666](#google-direct-api-adapter)

[16.3 Streaming Protocol	667](#16.3-streaming-protocol)

[SSE Event Types	667](#sse-event-types)

[Streaming Call with Provider Routing	668](#streaming-call-with-provider-routing)

[OpenAI-Compatible Streaming	669](#openai-compatible-streaming)

[Anthropic Direct Streaming	670](#anthropic-direct-streaming)

[Client-Side SSE Consumer	673](#client-side-sse-consumer)

[16.4 Token Accounting	675](#16.4-token-accounting)

[Token Usage Table	675](#token-usage-table)

[Recording Token Usage	677](#recording-token-usage)

[Aggregation RPCs	678](#aggregation-rpcs)

[16.5 Rate Limiting	680](#16.5-rate-limiting)

[Rate Limit Table	680](#rate-limit-table)

[Rate Limit Check	680](#rate-limit-check)

[Rate Limit Cleanup	683](#rate-limit-cleanup)

[Rate Limit Response	683](#rate-limit-response)

[16.6 Fallback Chain	684](#16.6-fallback-chain)

[Fallback Flow	684](#fallback-flow)

[Implementation	685](#implementation-5)

[Fallback Notification	688](#fallback-notification)

[16.7 Model Override (Chat Composer)	689](#16.7-model-override-\(chat-composer\))

[Override Priority (from Part 14\)	689](#override-priority-\(from-part-14\))

[Model Selector Widget	689](#model-selector-widget)

[16.8 Token Budget for Background Tasks	691](#16.8-token-budget-for-background-tasks)

[Background Model Configuration	691](#background-model-configuration)

[Background Token Tracking	692](#background-token-tracking)

[Background Cost Limits	692](#background-cost-limits)

[16.9 OpenRouter Integration	693](#16.9-openrouter-integration)

[OpenRouter-Specific Headers	693](#openrouter-specific-headers)

[OpenRouter Model ID Mapping	693](#openrouter-model-id-mapping)

[OpenRouter Billing	693](#openrouter-billing)

[OpenRouter Rate Limits	694](#openrouter-rate-limits)

[OpenRouter Model Availability Monitoring	694](#openrouter-model-availability-monitoring)

[16.10 Image Generation	695](#16.10-image-generation)

[Image Generation Flow	695](#image-generation-flow)

[Image Generation Detection	696](#image-generation-detection)

[16.11 Model Health Monitoring	697](#16.11-model-health-monitoring)

[Metrics per Model	697](#metrics-per-model)

[Health Thresholds	698](#health-thresholds)

[16.12 Cross-Reference Summary	698](#16.12-cross-reference-summary)

[PART 17: REAL-TIME & COLLABORATION	700](#part-17:-real-time-&-collaboration-1)

[17.1 Architecture Overview	700](#17.1-architecture-overview)

[Why Two Transports	701](#why-two-transports)

[17.2 Channel Architecture	701](#17.2-channel-architecture)

[Channel Registry	701](#channel-registry)

[Subscription Lifecycle	702](#subscription-lifecycle)

17.3 Chat Channel (`chat:{chat_id}`) 704

[Subscription Setup	704](#subscription-setup)

[Event Handlers	706](#event-handlers)

[New Message	706](#new-message)

[Message Update	707](#message-update)

[Chat Update	708](#chat-update)

[Auto-Rename Banner	708](#auto-rename-banner-1)

[Suggested Move Banner	710](#suggested-move-banner-1)

[17.4 Typing Indicators	711](#17.4-typing-indicators)

[Indicator Lifecycle	711](#indicator-lifecycle)

[Server-Side Broadcast	712](#server-side-broadcast)

[Client-Side Typing State	713](#client-side-typing-state)

[Typing Indicator UI	714](#typing-indicator-ui)

[Animated Dots	715](#animated-dots)

[Multi-Persona Typing	716](#multi-persona-typing)

[17.5 Stream Status Events	717](#17.5-stream-status-events)

[Status Events	717](#status-events)

[Server-Side Status Emission	718](#server-side-status-emission)

17.6 User Channel (`user:{user_id}`) 719

[Subscription Setup	719](#subscription-setup-1)

[Notification Handler	721](#notification-handler)

[Instruction Memory Handler	722](#instruction-memory-handler)

[Chat List Handler	723](#chat-list-handler)

[Persona Update Handler	724](#persona-update-handler)

17.7 Instance Channel (`instance:{instance_id}`) 725

[Subscription Setup	725](#subscription-setup-2)

17.8 Browser Channel (`browser:{session_id}`) 727

[Events	727](#events)

[Subscription	727](#subscription)

[17.9 Toast Notification System	728](#17.9-toast-notification-system)

[Toast Types	728](#toast-types)

[Toast Container	729](#toast-container)

[Individual Toast	729](#individual-toast)

[Toast State Management	730](#toast-state-management)

[Toast Entry Animation	731](#toast-entry-animation)

[17.10 Optimistic Updates	732](#17.10-optimistic-updates)

[Pattern: Send Message	732](#pattern:-send-message)

[Pattern: Pin Message	734](#pattern:-pin-message)

[Pattern: Delete Chat	735](#pattern:-delete-chat)

[Pattern: Update Memory	736](#pattern:-update-memory)

[Optimistic Update Rules	736](#optimistic-update-rules)

[17.11 Connection State Management	737](#17.11-connection-state-management)

[Connection State	737](#connection-state)

[Reconnection Banner	738](#reconnection-banner)

[Reconnection Behavior	740](#reconnection-behavior)

[17.12 Supabase Realtime Configuration	740](#17.12-supabase-realtime-configuration)

[Required Realtime Setup	740](#required-realtime-setup)

[Enabling Realtime in Supabase	741](#enabling-realtime-in-supabase)

[Realtime RLS	741](#realtime-rls)

[Channel Limits	742](#channel-limits)

[17.13 Presence (Future — v2)	742](#17.13-presence-\(future-—-v2\))

[Persona Activity Presence	742](#persona-activity-presence)

[Human Team Presence (Enterprise)	743](#human-team-presence-\(enterprise\))

[Presence UI (v2)	744](#presence-ui-\(v2\))

[17.14 Performance Considerations	745](#17.14-performance-considerations)

[Subscription Cleanup	745](#subscription-cleanup)

[Debouncing Cache Updates	745](#debouncing-cache-updates)

[Channel Deduplication	746](#channel-deduplication)

[Payload Size Management	746](#payload-size-management)

[Latency Targets	747](#latency-targets)

[17.15 Cross-Reference Summary	747](#17.15-cross-reference-summary)

[PART 18: SUPABASE DATABASE SCHEMA (Complete)	749](#part-18:-supabase-database-schema-\(complete\)-1)

[18.1 Prerequisites & Extensions	749](#18.1-prerequisites-&-extensions)

[18.2 Domain 1: User & Account	750](#18.2-domain-1:-user-&-account)

[profiles	750](#profiles)

[subscriptions	752](#subscriptions-1)

[api\_keys	753](#api_keys)

[credit\_transactions	753](#credit_transactions)

[18.3 Domain 2: Chat System	754](#18.3-domain-2:-chat-system)

[chats	754](#chats)

[chat\_participants	755](#chat_participants)

[messages	756](#messages)

[export\_history	757](#export_history)

[18.4 Domain 3: Spaces & Instances	758](#18.4-domain-3:-spaces-&-instances)

[instances	758](#instances)

[instance\_personas	759](#instance_personas)

[instance\_types	760](#instance_types)

[tasks	761](#tasks)

[workspace\_items	761](#workspace_items)

[18.5 Domain 4: Files	762](#18.5-domain-4:-files)

[files	762](#files)

[18.6 Domain 5: Personas	763](#18.6-domain-5:-personas)

[personas	763](#personas)

[persona\_skills	764](#persona_skills)

[persona\_boundaries	765](#persona_boundaries)

[persona\_memories	766](#persona_memories)

[persona\_health\_snapshots	767](#persona_health_snapshots)

[persona\_templates	768](#persona_templates)

[18.7 Domain 6: CogniGraph Memory System	769](#18.7-domain-6:-cognigraph-memory-system)

[memory\_edges	769](#memory_edges)

[memory\_checkpoints	770](#memory_checkpoints)

[instruction\_memory	771](#instruction_memory)

[18.8 Domain 7: Teams	772](#18.8-domain-7:-teams)

[teams	772](#teams)

[team\_members	773](#team_members)

[team\_tasks	774](#team_tasks)

[team\_runs	774](#team_runs)

[18.9 Domain 8: Browser	775](#18.9-domain-8:-browser)

[browser\_sessions	775](#browser_sessions)

[browser\_tabs	776](#browser_tabs)

[browser\_history	777](#browser_history)

[browser\_extracts	778](#browser_extracts)

[browser\_highlights	779](#browser_highlights)

[18.10 Domain 9: Search	780](#18.10-domain-9:-search)

[search\_history	780](#search_history)

[saved\_results	780](#saved_results)

[18.11 Domain 10: AI Model Routing	781](#18.11-domain-10:-ai-model-routing)

[model\_registry	781](#model_registry)

[model\_role\_assignments	782](#model_role_assignments)

[token\_usage	783](#token_usage)

[rate\_limit\_counters	784](#rate_limit_counters)

[18.12 Domain 11: System	784](#18.12-domain-11:-system)

[notifications	784](#notifications)

[activity\_log	785](#activity_log)

[18.13 RPC Functions	786](#18.13-rpc-functions)

[Utility Functions	786](#utility-functions)

[Seed Functions	787](#seed-functions)

[Search Functions	788](#search-functions)

[Memory Functions	790](#memory-functions)

[Analytics Functions	792](#analytics-functions)

[Token & Model Health Functions	794](#token-&-model-health-functions)

[18.14 Scheduled Jobs (pg\_cron)	795](#18.14-scheduled-jobs-\(pg_cron\))

[18.15 Storage Buckets	796](#18.15-storage-buckets)

[user-files	796](#user-files)

[persona-avatars	797](#persona-avatars)

[exports	797](#exports)

[browser-extracts	798](#browser-extracts)

[18.16 Entity Relationship Diagram	798](#18.16-entity-relationship-diagram)

[Foreign Key Reference Map	801](#foreign-key-reference-map)

[18.17 Schema Statistics	804](#18.17-schema-statistics)

[Tables by Domain	804](#tables-by-domain)

[18.18 Migration Order	805](#18.18-migration-order)

[PART 19: API ROUTES & EDGE FUNCTIONS	808](#part-19:-api-routes-&-edge-functions-1)

[19.1 REST API (Supabase Auto-Generated)	808](#19.1-rest-api-\(supabase-auto-generated\)-1)

[Authentication	808](#authentication)

[Client Access Pattern	808](#client-access-pattern)

[Endpoint Map	809](#endpoint-map)

[RPC Endpoints	812](#rpc-endpoints)

[19.2 Edge Functions	813](#19.2-edge-functions-1)

[Shared Infrastructure	813](#shared-infrastructure)

[Function Registry	815](#function-registry)

[19.3 Function 1: cipher-route	816](#19.3-function-1:-cipher-route)

[Request	817](#request)

[Response (SSE stream)	817](#response-\(sse-stream\))

[Pipeline Steps	818](#pipeline-steps)

[Typing Indicator Integration	818](#typing-indicator-integration)

[19.4 Function 2: cipher-memory	819](#19.4-function-2:-cipher-memory)

[Request	819](#request-1)

[Tasks (run in parallel via Promise.allSettled)	820](#tasks-\(run-in-parallel-via-promise.allsettled\))

[Background Model Usage	821](#background-model-usage)

[19.5 Function 3: cipher-cleanup	821](#19.5-function-3:-cipher-cleanup)

[Cleanup Tasks per User	821](#cleanup-tasks-per-user)

[19.6 Function 4: cipher-health	822](#19.6-function-4:-cipher-health)

[Processing per Persona	822](#processing-per-persona)

[19.7 Function 5: search	823](#19.7-function-5:-search)

[Request	823](#request-2)

[Response	824](#response)

[Implementation	824](#implementation-6)

[19.8 Function 6: analytics	826](#19.8-function-6:-analytics)

[Request	826](#request-3)

[Response	826](#response-1)

[Implementation	827](#implementation-7)

[19.9 Function 7: chat-export	827](#19.9-function-7:-chat-export)

[Request	828](#request-4)

[Response	828](#response-2)

[Implementation	828](#implementation-8)

[Format Generators	832](#format-generators)

[19.10 Function 8: browser-proxy	834](#19.10-function-8:-browser-proxy)

[Implementation	834](#implementation-9)

[Browser History Recording	837](#browser-history-recording)

[19.11 Function 9: browser-extract	837](#19.11-function-9:-browser-extract)

[Request	837](#request-5)

[Implementation	838](#implementation-10)

[19.12 Function 10: team-execute	839](#19.12-function-10:-team-execute)

[Request	839](#request-6)

[Execution Flow	840](#execution-flow-1)

[19.13 Function 11: validate-api-key	840](#19.13-function-11:-validate-api-key)

[Request	840](#request-7)

[Implementation	841](#implementation-11)

[19.14 Function 12: store-api-key	841](#19.14-function-12:-store-api-key)

[Request	841](#request-8)

[Implementation	842](#implementation-12)

[19.15 Function 13: stripe-webhook	843](#19.15-function-13:-stripe-webhook)

[Event Handling	844](#event-handling)

[19.16 Function 14: create-checkout-session	847](#19.16-function-14:-create-checkout-session)

[Request	848](#request-9)

[Implementation	848](#implementation-13)

[19.17 Function 15: files-zip	849](#19.17-function-15:-files-zip)

[Request	850](#request-10)

[Implementation	850](#implementation-14)

[19.18 Function 16: generate-embedding	851](#19.18-function-16:-generate-embedding)

[Request	852](#request-11)

[Implementation	852](#implementation-15)

[19.19 Environment Variables	853](#19.19-environment-variables)

[19.20 Deployment & Directory Structure	854](#19.20-deployment-&-directory-structure)

[Deployment Commands	855](#deployment-commands)

[19.21 Request/Response Summary	855](#19.21-request/response-summary)

[19.22 Error Handling Conventions	856](#19.22-error-handling-conventions)

[Error Response Shape	857](#error-response-shape)

[HTTP Status Code Usage	857](#http-status-code-usage)

[Retry Behavior	857](#retry-behavior)

[19.23 Cross-Reference: Functions by Source Part	858](#19.23-cross-reference:-functions-by-source-part)

[PART 20: FRONTEND COMPONENT ARCHITECTURE	860](#part-20:-frontend-component-architecture-1)

[20.1 Page Structure (Next.js App Router)	860](#20.1-page-structure-\(next.js-app-router\)-1)

[Route Tree	860](#route-tree)

[Root Layout	861](#root-layout)

[Authentication Gate	862](#authentication-gate)

[20.2 Provider Stack	862](#20.2-provider-stack)

[Provider Responsibilities	863](#provider-responsibilities)

[AuthProvider	864](#authprovider)

[ThemeProvider	866](#themeprovider)

[RealtimeProvider	868](#realtimeprovider)

[20.3 State Management	868](#20.3-state-management-1)

[Layer 1: Server State (TanStack Query)	868](#layer-1:-server-state-\(tanstack-query\))

[Layer 2: UI State (Zustand)	868](#layer-2:-ui-state-\(zustand\))

[Layer 3: URL State (Next.js searchParams)	869](#layer-3:-url-state-\(next.js-searchparams\))

[20.4 Zustand Stores	869](#20.4-zustand-stores)

[ui-store	869](#ui-store)

[navigation-store	871](#navigation-store)

[browser-store	873](#browser-store)

[20.5 TanStack Query Key Registry	875](#20.5-tanstack-query-key-registry)

[20.6 Custom Hooks	878](#20.6-custom-hooks)

[Data Hooks	878](#data-hooks)

[Operation Hooks	882](#operation-hooks)

[Streaming Hook	883](#streaming-hook)

[Utility Hooks	885](#utility-hooks)

[20.7 Component Hierarchy	886](#20.7-component-hierarchy)

[Application Shell	886](#application-shell)

[NotificationDropdown	887](#notificationdropdown)

[CommandPalette	888](#commandpalette)

[20.8 Screen Components	888](#20.8-screen-components)

[Home Screen	888](#home-screen)

[Chat Screen	889](#chat-screen)

[Search Screen	890](#search-screen)

[Spaces Screen (Instance List)	890](#spaces-screen-\(instance-list\))

[Instance Detail Screen	891](#instance-detail-screen)

[Files Screen	892](#files-screen)

[People Screen (Persona List)	892](#people-screen-\(persona-list\))

[Persona Detail Screen	893](#persona-detail-screen)

[Teams Screen	893](#teams-screen)

[Browser Screen	894](#browser-screen)

[Insights Screen	895](#insights-screen)

[Settings Screen	895](#settings-screen)

[20.9 Shared Component Library	897](#20.9-shared-component-library)

[Atomic Components	897](#atomic-components)

[Composite Components	898](#composite-components)

[Modal Components	899](#modal-components)

[20.10 Data Flow Patterns	900](#20.10-data-flow-patterns)

[Pattern 1: Standard CRUD (Client → PostgREST → RLS)	900](#pattern-1:-standard-crud-\(client-→-postgrest-→-rls\))

[Pattern 2: AI Message (Client → Edge Function → SSE)	900](#pattern-2:-ai-message-\(client-→-edge-function-→-sse\))

[Pattern 3: Realtime Subscription (Database → Supabase Realtime → Client)	901](#pattern-3:-realtime-subscription-\(database-→-supabase-realtime-→-client\))

[Pattern 4: Background Fetch (Component Mount → Query → Cache)	901](#pattern-4:-background-fetch-\(component-mount-→-query-→-cache\))

[20.11 TypeScript Interfaces	902](#20.11-typescript-interfaces)

[20.12 File Organization Summary	907](#20.12-file-organization-summary)

[20.13 Build & Development Tooling	910](#20.13-build-&-development-tooling)

[Package Dependencies	910](#package-dependencies)

[Environment Variables (Client-Side)	911](#environment-variables-\(client-side\))

[ESLint Configuration	911](#eslint-configuration)

[20.14 Cross-Reference Summary	911](#20.14-cross-reference-summary)

[PART 21: EMPTY STATES, ERROR HANDLING & LOADING STATES	913](#part-21:-empty-states,-error-handling-&-loading-states)

[21.1 Shared Components	913](#21.1-shared-components)

[EmptyState Component	913](#emptystate-component)

[ErrorBanner Component	914](#errorbanner-component)

[LoadingSkeleton Component	916](#loadingskeleton-component)

[InlineRetry Component	917](#inlineretry-component)

[21.2 Empty States by Screen	918](#21.2-empty-states-by-screen)

[Home Dashboard	918](#home-dashboard)

[Chat Screen	919](#chat-screen-1)

[Search Screen	921](#search-screen-1)

[Spaces (Instance List)	922](#spaces-\(instance-list\))

[Instance Detail	923](#instance-detail)

[Files Screen	924](#files-screen-1)

[People (Persona List)	925](#people-\(persona-list\))

[Persona Detail	925](#persona-detail)

[Teams Screen	926](#teams-screen-1)

[Browser Screen	927](#browser-screen-1)

[Insights Screen	928](#insights-screen-1)

[Settings Screen	929](#settings-screen-1)

[Command Palette	929](#command-palette)

[Notification Dropdown	930](#notification-dropdown-1)

[21.3 Error States	930](#21.3-error-states)

[Error Classification	930](#error-classification-1)

[Network Error	931](#network-error)

[Authentication Session Expired	931](#authentication-session-expired)

[Tier-Gated Feature (403)	932](#tier-gated-feature-\(403\))

[Rate Limit Hit (429)	933](#rate-limit-hit-\(429\))

[AI Generation Failure	935](#ai-generation-failure)

[File Upload Failure	936](#file-upload-failure)

[Browser Proxy Failure	938](#browser-proxy-failure)

[Data Query Failure (Generic)	939](#data-query-failure-\(generic\))

[Stripe/Payment Failure	940](#stripe/payment-failure)

[Export Failure	941](#export-failure)

[Team Execution Failure	941](#team-execution-failure)

[API Key Validation Failure	942](#api-key-validation-failure)

[21.4 Loading States	942](#21.4-loading-states)

[Page-Level Loading	942](#page-level-loading)

[Skeleton Screens	943](#skeleton-screens)

[Typing Indicator	947](#typing-indicator)

[Progress Indicators	948](#progress-indicators-1)

[Button Loading States	950](#button-loading-states)

[21.5 Toast Notifications	951](#21.5-toast-notifications)

[Appearance	951](#appearance)

[Behavior	952](#behavior)

[Common Toast Messages	952](#common-toast-messages)

[21.6 Confirmation Dialogs	954](#21.6-confirmation-dialogs)

[Appearance	954](#appearance-1)

[Actions Requiring Confirmation	954](#actions-requiring-confirmation)

[21.7 Offline Behavior	957](#21.7-offline-behavior)

[Detection	957](#detection-1)

[Offline Banner	957](#offline-banner)

[Optimistic Actions While Offline	958](#optimistic-actions-while-offline)

[21.8 Cross-Reference	958](#21.8-cross-reference)

[PART 22: TESTING & QUALITY ASSURANCE	960](#part-22:-testing-&-quality-assurance-1)

[22.1 Testing Frameworks & Tooling	960](#22.1-testing-frameworks-&-tooling)

[Installation	961](#installation)

[Vitest Configuration	961](#vitest-configuration)

[Playwright Configuration	962](#playwright-configuration)

[Test Setup	963](#test-setup)

[22.2 Test Directory Structure	964](#22.2-test-directory-structure)

[22.3 Mock Infrastructure	967](#22.3-mock-infrastructure)

[Mock Supabase Client	967](#mock-supabase-client)

[Test Data Factories	969](#test-data-factories)

[MSW Handlers	971](#msw-handlers)

[22.4 Unit Tests	973](#22.4-unit-tests)

[Utility Functions	973](#utility-functions-1)

[Zustand Stores	975](#zustand-stores)

[Cipher Routing Algorithm	977](#cipher-routing-algorithm)

[Token Budgeting	979](#token-budgeting)

[Memory Extraction	980](#memory-extraction-1)

[Edge Function Logic	980](#edge-function-logic)

[Unit Test Coverage Targets	981](#unit-test-coverage-targets)

[22.5 Integration Tests	982](#22.5-integration-tests)

[Database: RLS Policies	982](#database:-rls-policies)

[Database: RPC Functions	984](#database:-rpc-functions)

[Database: Triggers	985](#database:-triggers)

[Pipelines	986](#pipelines)

[22.6 Component Tests	988](#22.6-component-tests)

[Component Test Coverage Targets	991](#component-test-coverage-targets)

[22.7 End-to-End Tests	992](#22.7-end-to-end-tests)

[Test Accounts	992](#test-accounts)

[Critical Flow Tests	992](#critical-flow-tests)

[22.8 Performance Benchmarks	998](#22.8-performance-benchmarks)

[Performance Test Suite	999](#performance-test-suite)

[22.9 CI/CD Pipeline	1000](#22.9-ci/cd-pipeline)

[GitHub Actions Workflow	1000](#github-actions-workflow)

[Package.json Scripts	1002](#package.json-scripts)

[Quality Gates	1002](#quality-gates)

[22.10 Launch Readiness Checklist	1003](#22.10-launch-readiness-checklist)

[Functional Readiness	1003](#functional-readiness)

[Performance Readiness	1003](#performance-readiness)

[Security Readiness	1003](#security-readiness)

[Accessibility Readiness	1004](#accessibility-readiness)

[Error Handling Readiness	1004](#error-handling-readiness)

[22.11 Cross-Reference	1004](#22.11-cross-reference)

[PART 23: DEPLOYMENT & INFRASTRUCTURE	1006](#part-23:-deployment-&-infrastructure-1)

[23.1 Architecture Overview	1006](#23.1-architecture-overview)

[23.2 Environments	1007](#23.2-environments)

[Development (Local)	1007](#development-\(local\))

[Staging	1009](#staging)

[Production	1010](#production)

[23.3 Database Migration Workflow	1011](#23.3-database-migration-workflow)

[Migration File Structure	1011](#migration-file-structure)

[Migration Commands	1012](#migration-commands)

[Migration Rules	1012](#migration-rules)

[Schema Change Process	1012](#schema-change-process)

[23.4 Edge Function Deployment	1013](#23.4-edge-function-deployment)

[Deployment Process	1013](#deployment-process)

[Function Directory Structure (Part 19, Section 19.20)	1013](#function-directory-structure-\(part-19,-section-19.20\))

[Function Configuration	1014](#function-configuration)

[Deployment Verification	1015](#deployment-verification)

[23.5 Environment Variable Management	1015](#23.5-environment-variable-management)

[Complete Variable Registry	1015](#complete-variable-registry)

[Secret Rotation	1017](#secret-rotation)

[Environment Parity	1017](#environment-parity)

[23.6 CI/CD Pipeline	1018](#23.6-ci/cd-pipeline)

[Branch Strategy	1018](#branch-strategy)

[Pipeline Stages	1018](#pipeline-stages)

[GitHub Actions Workflow	1019](#github-actions-workflow-1)

[Vercel Configuration	1020](#vercel-configuration)

[Vercel Deployment Settings	1021](#vercel-deployment-settings)

[23.7 Monitoring & Observability	1021](#23.7-monitoring-&-observability)

[Frontend Monitoring	1021](#frontend-monitoring)

[Backend Monitoring	1022](#backend-monitoring)

[Custom Metrics (activity\_log table)	1025](#custom-metrics-\(activity_log-table\))

[23.8 Security Hardening	1026](#23.8-security-hardening)

[Transport Security	1026](#transport-security)

[Authentication Security	1026](#authentication-security)

[Data Security	1026](#data-security)

[HTTP Security Headers	1027](#http-security-headers)

[Content Security Policy	1027](#content-security-policy)

[Browser Workspace Security	1028](#browser-workspace-security)

[Dependency Security	1029](#dependency-security)

[23.9 Performance Optimization	1029](#23.9-performance-optimization)

[Frontend Optimizations	1029](#frontend-optimizations)

[Database Optimizations	1031](#database-optimizations)

[Edge Function Optimizations	1033](#edge-function-optimizations)

[23.10 Backup & Recovery	1033](#23.10-backup-&-recovery)

[Database Backups	1033](#database-backups)

[Storage Backups	1034](#storage-backups)

[Recovery Procedures	1034](#recovery-procedures)

[Vercel Rollback	1035](#vercel-rollback)

[23.11 Scaling Considerations	1035](#23.11-scaling-considerations)

[Scaling Levers	1035](#scaling-levers)

[When to Consider Additional Infrastructure	1036](#when-to-consider-additional-infrastructure)

[23.12 Domain & DNS Configuration	1036](#23.12-domain-&-dns-configuration)

[Domain Setup	1036](#domain-setup)

[DNS Records	1037](#dns-records)

[Vercel Domain Configuration	1037](#vercel-domain-configuration)

[Supabase Custom Domain (Optional)	1037](#supabase-custom-domain-\(optional\))

[23.13 Stripe Configuration	1037](#23.13-stripe-configuration)

[Stripe Products & Prices	1037](#stripe-products-&-prices)

[Webhook Configuration	1038](#webhook-configuration)

[Customer Portal	1038](#customer-portal)

[23.14 Incident Response	1039](#23.14-incident-response)

[Severity Levels	1039](#severity-levels)

[Response Procedures	1039](#response-procedures)

[Status Page	1040](#status-page)

[Health Check Endpoints	1040](#health-check-endpoints)

[23.15 Operational Runbooks	1041](#23.15-operational-runbooks)

[Runbook: Deploy a Database Migration	1041](#runbook:-deploy-a-database-migration)

[Runbook: Add a New Edge Function	1042](#runbook:-add-a-new-edge-function)

[Runbook: Rotate OpenRouter API Key	1042](#runbook:-rotate-openrouter-api-key)

[Runbook: Respond to Stripe Webhook Failure	1042](#runbook:-respond-to-stripe-webhook-failure)

[Runbook: Emergency Vercel Rollback	1042](#runbook:-emergency-vercel-rollback)

[23.16 Cross-Reference	1043](#23.16-cross-reference)

---

## Document Purpose 

This PRD will serve as the single source of truth for Claude Code to build a fully functional aiConnected OS platform. Every section must contain enough implementation detail — including database schemas, API contracts, component specifications, state management, and edge cases — for an AI coding agent to produce working code without ambiguity.

---

## PART 1: PRODUCT FOUNDATION 

### 1.1 Product Vision & Definition 

- One-sentence definition from the docs: "aiConnected is a fluid interaction platform where persistent AI personas act as believable collaborators — operating within explicit skill boundaries — while a continuous chat-based cognitive backbone preserves memory, context, and coordination across any activity the user chooses."  
- The three problems being solved: lack of real memory, the omniscience illusion, treating AI as disposable tools  
- What makes this different from ChatGPT/Claude/Perplexity (persistent cognition, modular intelligence, workflow-native, persona governance)  
- Target users: individual professionals, creators, agencies, power users, eventually enterprise

### 1.2 Architecture Overview (Four Layers) 

- **Layer 1: Cipher** — Invisible orchestration engine. Never user-facing. Handles routing, memory arbitration, skill validation, safety, coordination. The "unrestricted cognition layer" that governs everything but is never addressable.  
- **Layer 2: CogniGraph** — Structured knowledge graph memory system. Categories → Concepts → Topics. Open Thinking Layer (transient) vs Closed Thinking Layer (committed). Checkpoint-based context assembly.  
- **Layer 3: Personas** — Bounded AI collaborators with finite skills, explicit limits, persistent identity, and human-like learning. Never omniscient. Can refuse, admit limits, suggest specialists.  
- **Layer 4: Fluid UI** — Adaptive interface that reshapes around user activity. Chat as spine, progressive disclosure, no hard modes.  
- Diagram: How layers communicate (Cipher → Personas → User; CogniGraph serves all layers)

### 1.3 Technology Stack 

- **Frontend:** Next.js 14+ (App Router), React 18+, TypeScript, Tailwind CSS, shadcn/ui  
- **Backend:** Supabase (PostgreSQL, Auth, Realtime, Edge Functions, Storage)  
- **AI Routing:** OpenRouter API (multi-model), BYOK support  
- **Real-time:** Supabase Realtime (presence, broadcasts, Postgres changes)  
- **File Storage:** Supabase Storage buckets (with external integrations planned: Google Drive, Dropbox, S3)  
- **Search:** Supabase pg\_vector for semantic search, full-text search via PostgreSQL  
- **Deployment:** Vercel (frontend), Supabase Cloud (backend)  
- **Browser Engine:** Embedded iframe/webview with DOM extraction layer (v1), Chrome extension companion mode (future)

### 1.4 Design System & Theming 

- Brand palette: Navy (\#021220, \#031c33), Blue accent (\#2e95f3), DM Sans typography  
- Light/dark mode with complete token set (reference prototype theme objects)  
- Full token inventory: bg, surface, surfaceAlt, text, textSec, textMuted, textFaint, border, borderSubtle, accent, accentText, dot (active/idle/sleeping), inputBg, pinBg, pinBorder  
- Sidebar tokens: dark navy background regardless of mode, with own color scale  
- Shared atoms: StatusDot, Avatar (initial-based), MemoryTypeIcon  
- Spacing system, border radius conventions, typography scale  
- Responsive breakpoints: mobile (\<768px), tablet, desktop  
- Animation conventions: 0.15s–0.2s ease transitions, no jarring mode switches

---

## PART 2: AUTHENTICATION & USER MANAGEMENT 

### 2.1 Authentication 

- Supabase Auth with email/password (v1)  
- OAuth providers: Google, GitHub (v1); Apple, Microsoft (v2)  
- Session management, token refresh  
- Password reset flow  
- Email verification

### 2.2 User Profile 

- Database schema: users table extending Supabase auth.users  
- Fields: display\_name, avatar\_url, preferences (JSON), tier, created\_at, onboarding\_completed  
- User preferences: theme (light/dark/system), default\_model, interface\_mode (standard/power), notification settings

### 2.3 Pricing Tiers & Feature Gating 

- Four tiers: Free ($0), Plus ($19.99/mo), Premium ($49.99/mo), Pro ($99.99/mo)  
- Tier limits table: instances, personas, storage, chat length, model access, features  
- BYOK (Bring Your Own Key): Available all tiers, unlocks unlimited chat when connected  
- Credits system for free users (pay-as-you-go feature unlocks)  
- Feature gate checking: middleware/utility that returns allowed features per tier  
- Stripe integration for billing (subscription \+ credits)  
- Database schema: subscriptions, credit\_transactions, api\_keys tables

### 2.4 Onboarding Flow 

- 4-step welcome tour (reference prototype OnboardingOverlay)  
- Step 1: Welcome \+ product explanation  
- Step 2: Instances concept  
- Step 3: Personas concept  
- Step 4: Memory \+ CogniGraph concept  
- First-run defaults: no instances, no personas, simple interface mode, local storage  
- Progressive feature discovery: system suggests enabling features as user grows  
- "Replay Welcome Tour" in Settings

---

## PART 3: APPLICATION SHELL & NAVIGATION 

### 3.1 Sidebar Navigation 

- Collapsible sidebar: expanded (200px with labels) ↔ collapsed (56px, icons only)  
- Hover-expand behavior when collapsed (sHover state)  
- Auto-collapse when entering browser mode  
- Dark navy background (\#0a1628) regardless of theme  
- Navigation items: Home, Chat, Search, Spaces, Files, People, Teams (divider), Browser, Insights (divider), Settings  
- "Advanced" section toggle for Teams/Browser/Insights  
- User profile chip at bottom with tier badge  
- Mobile: hamburger overlay, full-screen sidebar on open  
- Keyboard shortcut: ⌘. to toggle

### 3.2 Top Bar 

- Breadcrumb navigation with dynamic depth (screen → sub-context → detail)  
- Sidebar collapse toggle adjacent to first breadcrumb  
- Right-side actions: \+New button, Chat drawer toggle (chat screen only), Notification bell with unread dot, Theme toggle (sun/moon), Right panel toggle (chat screen only)  
- Mobile: hamburger menu replaces sidebar toggle

### 3.3 Command Palette 

- Triggered by \+New button or ⌘N  
- Search input with real-time filtering  
- Quick actions: New Chat, New Instance, New Persona, Search, Upload File  
- Each action shows keyboard shortcut  
- ESC to close

### 3.4 Notification System 

- Database schema: notifications table (id, user\_id, type, title, text, read, metadata JSON, created\_at)  
- Types: persona (activity updates), system (usage alerts, weekly summaries), chat (mentions, completions)  
- Dropdown from bell icon: latest 5–10, mark all read, link to full history  
- Full notification history modal with all items  
- Unread indicator (red dot on bell)  
- Real-time delivery via Supabase Realtime

### 3.5 Keyboard Shortcuts 

- Full shortcut map (reference prototype): ⌘N, ⌘K, ⌘/, ⌘⇧I, ⌘⇧P, ⌘U, ⌘D, ⌘., Esc, ⌘E  
- Shortcuts modal (⌘/ or ? button)  
- Global listener with screen-aware context

---

## PART 4: HOME / DASHBOARD SCREEN 

### 4.1 Layout 

- Centered content (max-width 900px)  
- Greeting: `Welcome back, {name}`  
- Subtitle: "Here's your workspace at a glance."

### 4.2 Quick Stats 

- 4-column grid (2 on mobile): Instances count, Personas count, Chats count, Tasks count  
- Each card: icon, large number (36px, weight 200), label  
- Pulls from aggregate queries

### 4.3 Recent Activity Feed 

- Chronological list of platform-wide activity  
- Each item: action text, source instance/context, timestamp  
- Database: activity\_log table (id, user\_id, actor\_type, actor\_id, action, entity\_type, entity\_id, metadata, created\_at)  
- Limit to 20 most recent, with pagination

### 4.4 Empty State (First-Time User) 

- No stats, replaced with getting-started cards  
- "Create your first Instance", "Meet your first Persona", "Start a conversation"  
- Links to onboarding replay

---

## PART 5: CHAT SYSTEM (Core) 

### 5.1 Data Model 

- **chats** table: id, user\_id, instance\_id (nullable), title, auto\_title, participants (persona\_id\[\]), tags, pinned, archived, deleted\_at, created\_at, updated\_at  
- **messages** table: id, chat\_id, sender\_type (user/persona/system/cipher), sender\_id, content (text), content\_blocks (JSON — for structured content, inline components, tool outputs), model\_used, persona\_id, metadata (JSON), pinned, created\_at  
- **chat\_participants** join table: chat\_id, persona\_id, added\_at, removed\_at  
- Soft delete with 30-day retention (Recently Deleted)  
- Auto-title generation after 3+ exchanges

### 5.2 Chat List Drawer (Left Panel) 

- Toggle via top bar button, defaults closed  
- Width: 280px (desktop), full screen (mobile)  
- Search bar with real-time filtering  
- Chats grouped by Instance (section headers)  
- Each chat item: title, preview, timestamp, persona avatars, tags  
- Hover actions: Archive, Delete  
- Multi-select mode: checkbox UI, batch actions (Move, Archive, Delete)  
- Recently Deleted section: collapsible, shows expiry countdown, restore/permanent delete actions  
- Unread indicators (future)

### 5.3 Message List 

- Full-height scrollable area  
- Message layout: Avatar (26px) → sender name \+ role \+ timestamp \+ pin icon  
- Content at 34px left indent (paddingLeft: 34\)  
- Hover actions bar (appears on message hover): Pin, Copy, Fork, More  
- Cipher routing notes: subtle italic text above routed messages ("Routed to Sally (Web Design)")  
- Memory extraction indicators: "1 memory saved" below qualifying messages  
- Inline interactive components: Server-driven UI blocks (task cards, pricing tables, etc.) rendered within message flow  
- Regenerate button on last AI message  
- Pin toggle with visual indicator (background color change, left border, pin icon)  
- Empty state: "No messages match this filter."

### 5.4 Message Composer 

- Bottom-pinned input bar with border and background  
- Left side: PersonaSelector (@mention dropdown), ModelSelector (model picker with provider info)  
- Center: text input, dynamic placeholder (`Message everyone...` or `Message {persona}...`)  
- Right side: Mic, Paperclip (file attach), Send (ArrowUp)  
- Draft persistence: auto-save indicator appears when typing ("Draft saved" with green dot)  
- Model selector: dropdown showing available models with provider, tag (fast/balanced/powerful)

### 5.5 Auto-Rename & Suggested Move Banners 

- Auto-rename: appears after sufficient context, shows suggested title, Apply/Dismiss actions  
- Suggested move: appears when chat content matches an existing Instance, shows Instance name, Move/Choose/Dismiss actions  
- Both are dismissible banners above the message list  
- Triggered by Cipher analysis (background job after N messages)

### 5.6 Right Panel (Sidebar) 

- Toggle via top bar button, defaults closed  
- Width: 250px, 4 tabs: Nav, Filters, Pinned, People  
- **Nav tab:** ChatNavigator — chronological conversation map grouped by date, expandable items with summaries, search within nav  
- **Filters tab:** Message filter chips (All/Sent/Received/Pinned/Links/Media), in-chat search input, filtered count display, Export button. Active filter indicator (dot) on tab.  
- **Pinned tab:** List of pinned messages with sender, time, preview. Empty state prompt.  
- **People tab:** Participant management (chip-style with remove X, \+ Add button), persona detail cards with status dots, click-through to persona profile.

### 5.7 Export System 

- Modal with format options: Markdown, PDF, JSON, HTML  
- Scope selector: Full conversation / Filtered only  
- Encrypted export (Pro tier)  
- Database: export\_history table for audit

### 5.8 AI Response Pipeline 

- User sends message → Cipher determines routing  
- Routing logic: explicit @mention overrides → skill matching → conversation context → default persona  
- Selected persona generates response via assigned model (from model role assignments)  
- Streaming response with typing indicator  
- Response metadata stored: model\_used, routing\_reason, token\_count, latency  
- Retry logic: automatic fallback to secondary model on failure  
- Stop generation button during streaming

---

## PART 6: SEARCH SCREEN 

### 6.1 Layout 

- Centered (max-width 700px)  
- Large search input (16px font) with search icon  
- Filter chips below: All, Web, Chats, Files, Memory, Internal  
- Results list below filters

### 6.2 Search Implementation 

- **Chats search:** PostgreSQL full-text search on messages.content \+ chats.title  
- **Files search:** Full-text on file names \+ metadata  
- **Memory search:** pg\_vector semantic search on CogniGraph nodes  
- **Web search:** Integration with search API (Tavily, Brave, or similar)  
- **Internal search:** Across instances, personas, settings  
- Combined ranking with source indicators

### 6.3 Search Results 

- Each result: icon (by type), title, preview snippet, source badge, timestamp  
- Action buttons per result: Chat with, Send to Instance, Save  
- "Chat with" opens a new chat with the result as context  
- "Send to Instance" moves/copies the result to a chosen instance

### 6.4 Search History & Saved Searches 

- Recent searches list (persisted per user)  
- Saved searches with custom labels  
- Database: search\_history, saved\_searches tables

---

## PART 7: SPACES / INSTANCES SCREEN 

### 7.1 Data Model 

- **instances** table: id, user\_id, name, type (project/client/personal/research/custom), description, icon, color, settings (JSON), persona\_ids\[\], created\_at, updated\_at, archived\_at  
- **instance\_settings**: inherits from type → global → default. JSON structure for model\_preferences, voice\_tone, file\_handling, cleanup\_rules, visibility  
- Settings cascade: Instance Settings → Type Template → Global Instance Settings → Platform Defaults

### 7.2 Instance List 

- Grid of instance cards  
- Each card: icon, name, type badge, chat count, file count, persona avatars, last activity time  
- Click to enter instance detail  
- "+ New Instance" card  
- Sort/filter options (by type, activity, name)

### 7.3 Instance Detail View 

- Header: Instance name, type, stats, overflow menu (Rename, Change Type, Export, Archive, Delete)  
- 6 tabs: Overview, Tasks, Workspace, Chats, Files, Settings

#### 7.3.1 Overview Tab 

- **Open Forum Chat:** Persistent shared conversation for the instance. Shows recent messages in a compact view with "Expand" button to open full chat. All assigned personas participate.  
- **Persona Roster:** Visual list of personas assigned to this instance with status dots, role labels. Add/remove personas.  
- **Tasks Summary:** Count of open/completed tasks with progress indicators.  
- **Activity Log:** Recent actions within this instance.

#### 7.3.2 Tasks Tab 

- Task board (Kanban-style): columns for To Do, In Progress, Review, Done  
- Each task card: title, assignee (persona), priority, due date  
- Database: tasks table (id, instance\_id, title, description, status, assignee\_persona\_id, priority, due\_date, created\_at, completed\_at)  
- Drag-and-drop reordering  
- Quick-add task inline

#### 7.3.3 Workspace Tab 

- Canvas/whiteboard-style view for pinned content  
- Sections with headers, drag-reorder  
- Components: messages promoted from chats, files, links, notes  
- List view and board view toggle  
- Database: workspace\_items table (id, instance\_id, section, title, content, type, source\_chat\_id, source\_message\_id, position, created\_at)

#### 7.3.4 Chats Tab 

- List of all chats within this instance  
- Search, filter by persona, sort by date  
- Quick-start new chat within instance

#### 7.3.5 Files Tab 

- File browser scoped to instance  
- Upload, organize into folders  
- File metadata: name, type, size, origin (uploaded/AI-generated), visibility, created\_at  
- Preview for images and documents

#### 7.3.6 Settings Tab 

- **Settings Cascade:** Visual hierarchy showing inherited vs overridden values (Instance → Type → Global)  
- **Model Roles:** Override model assignments for this instance  
- **Storage Config:** Local vs external vs hybrid (tier-gated)  
- **Cleanup Rules:** Auto-rename, suggested moves, scheduled cleanup frequency  
- **Persona Defaults:** Default personas for new chats in this instance

---

## PART 8: GLOBAL FILES SCREEN 

### 8.1 Data Model 

- **files** table: id, user\_id, instance\_id (nullable), chat\_id (nullable), name, type (pdf/image/doc/code/other), mime\_type, size\_bytes, storage\_path, origin (uploaded/ai\_generated), visibility (visible/instance/hidden), tags, metadata (JSON), created\_at  
- Supabase Storage bucket: `user-files/{user_id}/`

### 8.2 File Grid View 

- Search bar at top  
- Filter chips: All, Uploads, AI Generated  
- Grid of file cards: thumbnail/icon, name, size, source instance, date  
- Bulk select mode: checkbox on each card  
- Batch actions bar: Download, Move to Instance, Delete  
- Click to preview/download

### 8.3 File Upload 

- Drag-and-drop zone  
- Click to browse  
- Progress indicator  
- Auto-attach to active instance/chat if in context  
- Size limits per tier

---

## PART 9: PEOPLE / PERSONAS SCREEN 

### 9.1 Data Model 

- **personas** table: id, user\_id, name, role, personality (text), avatar\_config (JSON), status (active/idle/sleeping), mood (text), skill\_ids\[\], skill\_ceiling (int, default 10), visibility (visible/private), settings (JSON), created\_at, updated\_at  
- **persona\_skills** table: id, persona\_id, name, category, level (1-5), learnable (bool), temporary (bool), expires\_at, created\_at  
- **persona\_boundaries** table: id, persona\_id, type (will\_do/wont\_do/escalation), description, created\_at  
- **persona\_deployments** table: id, persona\_id, instance\_id, role\_in\_instance, deployed\_at, removed\_at  
- **persona\_memories** table: id, persona\_id, type (decision/fact/preference/skill), content, source\_chat\_id, source\_message\_id, confidence, tags, active (bool), created\_at, updated\_at

### 9.2 Persona List View 

- Grid of persona cards  
- Each card: avatar (initial-based, colored ring by status), name, role, status dot, mood indicator, skill count badge  
- Click to enter persona detail  
- Overflow menu: Edit, Duplicate, Export, Delete

### 9.3 Persona Detail View 

- Header: large avatar, name, role, status, mood  
- 6 tabs: Overview, Identity, Boundaries, Memory, Skills, Health

#### 9.3.1 Overview Tab 

- Deployments list: which instances this persona is assigned to, with role and "View Instance" link  
- Conversation history: recent chats involving this persona  
- Quick stats: total memories, total decisions, active skills

#### 9.3.2 Identity Tab 

- Editable fields: Name, Role, Personality (long text)  
- Visibility toggle (visible to all instances / private)  
- Identity is fixed once created (personality evolves through experience, not manual editing — except by explicit user override)

#### 9.3.3 Boundaries Tab 

- Will Do list: explicitly allowed capabilities  
- Won't Do list: explicitly forbidden actions  
- Escalation Rules: when to defer to user or suggest another persona  
- Skill Ceiling: maximum number of skills (default 10\)  
- Add/edit/remove boundary rules

#### 9.3.4 Memory Tab 

- List of all memories with type icons (decision/fact/preference/skill)  
- Filter chips: All, Decisions, Facts, Preferences, Skills  
- Bulk select with batch actions: Deactivate, Delete, Export  
- Each memory: content, source link, confidence, date, active toggle  
- Memory search

#### 9.3.5 Skills Tab 

- List of current skills with level indicators (1-5)  
- Learnable toggle per skill  
- Temporary vs permanent indicator  
- Add skill manually  
- Skill capacity: `{current}/{ceiling} skills`  
- Learning consent flow: persona proposes learning → user approves → skill added

#### 9.3.6 Health Tab 

- Mood indicator with history  
- Status dot (active/idle/sleeping) with explanation  
- Health metrics: response quality trend, memory growth rate, skill utilization  
- Recommendations: "Consider adding X skill" or "This persona has conflicting memories"

### 9.4 Create Persona Modal 

- 3-step wizard  
- Step 1: Name, Role, Personality description  
- Step 2: Initial skills (select from categories or type custom)  
- Step 3: Boundaries (will do / won't do quick setup), deploy to instance(s)  
- Template option: start from a saved template  
- Database: persona\_templates table for saved/shared configurations

---

## PART 10: TEAMS SCREEN (Agentic Teams) 

### 10.1 Data Model 

- **teams** table: id, user\_id, name, description, type (short\_term/long\_term/recurring), status (active/paused/completed), persona\_ids\[\], plan (JSON), progress (0-100), last\_run\_at, created\_at  
- **team\_runs** table: id, team\_id, status, started\_at, completed\_at, result (JSON), logs (JSON)

### 10.2 Teams List 

- Header with filter chips: All, Short-Term, Long-Term, Recurring  
- Team cards: name, type badge, progress bar, status, member count (persona avatars), last run time  
- "Executive Teams" teaser: enterprise tier gate with upgrade prompt  
- "+ New Team" button

### 10.3 Team Detail (Future — v2) 

- Plan editor  
- Persona assignment with roles (Orchestrator, Manager, Worker)  
- Run history with logs  
- Three-tier hierarchy: Orchestrator → Managers → Workers  
- Only Orchestrator speaks to user; Managers coordinate; Workers execute single tasks

---

## PART 11: BROWSER / CO-BROWSING WORKSPACE 

### 11.1 Architecture Overview 

This is a fully functional embedded browser experience where personas can observe, analyze, highlight, and extract content from web pages alongside the user. The browser is not a separate application — it is a workspace view within aiConnected that maintains full chat continuity.

### 11.2 Data Model 

- **browser\_sessions** table: id, user\_id, instance\_id (nullable), persona\_id, status (active/closed), created\_at, closed\_at  
- **browser\_tabs** table: id, session\_id, url, title, favicon\_url, position, active, opened\_at, closed\_at  
- **browser\_history** table: id, user\_id, url, title, persona\_id, visit\_count, last\_visited\_at  
- **browser\_extracts** table: id, user\_id, session\_id, tab\_id, instance\_id, title, content (text/HTML), source\_url, extract\_type (text/table/image/full\_page), metadata (JSON), created\_at  
- **browser\_highlights** table: id, tab\_id, persona\_id, selector (CSS selector or XPath), content\_preview, note, created\_at

### 11.3 Session Manager Screen (Non-Browse Mode) 

- Three sub-tabs: Active Sessions, History, Saved Extracts  
- **Active Sessions:** List of open sessions with URL, action description, persona, timestamp. Click to resume.  
- **History:** Chronological list with URL, persona, time, extract count. "Revisit" and delete actions.  
- **Saved Extracts:** Cards showing extracted content title, source URL, destination instance, timestamp.  
- "Open Browser" button to enter browse mode (collapses main sidebar)

### 11.4 Browser Engine (Core — MUST BE FULLY FUNCTIONAL) 

This section defines the full browser implementation. The browser must actually navigate real websites, not simulate them.

#### 11.4.1 Web Viewport 

- **Implementation:** `<iframe>` with a server-side proxy, OR `<webview>` tag in Electron/Tauri desktop app, OR a CORS proxy service for the web version  
- **Proxy Architecture (Web Version):**  
  - Server-side proxy endpoint: `/api/browser/proxy?url={encoded_url}`  
  - Proxy fetches the target URL server-side, rewrites relative URLs to absolute, injects aiConnected's overlay scripts  
  - Handles HTTPS, cookies (session-scoped), redirects  
  - Content-Security-Policy handling: strip restrictive CSP headers from proxied responses  
  - Limitations to document: some sites will block proxy access (banking, auth-walled content). The PRD must define graceful fallback messaging.  
- **Alternative (Desktop App — Future):**  
  - Electron BrowserView or Tauri WebView2 for native browser embedding  
  - Full cookie/session support, no proxy needed  
  - Extension-like DOM access  
- **Viewport rendering:** Full-width flexible area, real scrolling, real links, real content

#### 11.4.2 Navigation System 

- **URL Bar:** Editable URL input in the floating nav bar. Supports direct URL entry and search queries.  
- **Back/Forward:** Full history stack per tab (maintain navigation history array)  
- **Refresh:** Reload current URL through proxy  
- **Tab Management:**  
  - Tab strip: horizontal row of 26×26px favicon squares  
  - Active tab: accent outline, full opacity; inactive: 55% opacity  
  - Click tab to switch (updates viewport URL)  
  - Expand button (Maximize2 icon): floating popup showing full tab names  
  - Close tab via popup list  
  - New tab (+ button in strip)  
  - Tab state: url, title, favicon, scroll\_position, navigation\_history\[\]  
  - Maximum tabs per session (configurable, suggest 10\)

#### 11.4.3 Floating Navigation Bar 

- Position: fixed bottom-center of viewport  
- Style: dark translucent bar (rgba(20,20,20,0.88)) with backdrop blur  
- Contents: Back, Forward, Refresh, URL input, Highlight tool, Extract button, View switcher, Minimize button  
- Auto-hide after 30 seconds of inactivity (browserNavVisible state)  
- Minimize to compass dot: click dot to restore  
- The nav bar must be fully functional — every button must work

#### 11.4.4 Page Interaction Layer 

- **DOM Extraction:** When a page loads through the proxy, inject a script that:  
  - Extracts readable text content (using Readability.js or similar)  
  - Identifies structural elements (headings, tables, lists, images)  
  - Sends extracted content to the chat context so personas can "read" the page  
- **Highlight Tool:** User or persona can highlight sections of the page  
  - Visual: blue border (2px solid \#2e95f3) around highlighted section with persona attribution badge  
  - Storage: CSS selector \+ content preview saved to browser\_highlights  
  - Persona can say "I've highlighted the pricing section" and the UI shows it  
- **Extract to Instance:** Select content → save to browser\_extracts → optionally push to an instance's workspace  
  - Extract types: selected text, full section, table data, images, full page snapshot  
- **Quick Action Chips:** Contextual actions that appear based on page content  
  - "Summarize Page", "Extract Pricing", "Find Contact Info", "Compare to Instance"  
  - Powered by Cipher analyzing the extracted DOM

#### 11.4.5 Security & Limitations 

- Proxy must strip dangerous scripts (XSS prevention)  
- No access to user's actual browser cookies/sessions (isolated environment)  
- Login-required pages: show message "This page requires authentication. You can copy the URL to browse directly."  
- Rate limiting on proxy requests  
- Content filtering for harmful content  
- Clear documentation of what works vs what doesn't in proxy mode

### 11.5 Co-Browse Chat Integration (5 View Modes) 

All modes maintain the same chat state. Switching views does NOT reset conversation.

#### 11.5.1 Float Bar (Default) 

- Floating chat bubble at bottom-center of viewport  
- Shows: last persona message preview (avatar, name, text), text input  
- Width: 440px (90% on mobile)  
- Mic and send buttons  
- Overlaid on the web viewport with shadow

#### 11.5.2 Icon Only 

- Minimized to a 44px persona avatar dot at bottom-right  
- Green status dot indicator  
- Click to expand to Float Bar  
- Least intrusive mode

#### 11.5.3 Sidebar (320px) 

- Fixed right panel with tab strip at top, persona header, full chat history, composer  
- Web viewport takes remaining width  
- Tab strip moves into sidebar top (not viewport)

#### 11.5.4 50/50 Split 

- Equal width: viewport left, chat right  
- Full chat experience in right half  
- Tab strip in chat panel top

#### 11.5.5 Chat Only 

- Full-width chat, no viewport visible  
- For when the user wants to discuss findings without the page  
- Tab strip still visible for reference

### 11.6 Page Awareness System 

- Badge indicator: `"{Persona name} is reading this page"` (top-right of viewport)  
- Dismissible  
- Triggered when a persona has been given the page's extracted content  
- Persona can reference specific sections of the page in conversation

### 11.7 View Switcher Menu 

- Accessible from floating nav bar  
- Dropdown with 5 layout options (icon \+ label \+ description)  
- Additional options: "Minimize nav to dot", "Sessions & History" (returns to session manager)

---

## PART 12: INSIGHTS & ANALYTICS SCREEN 

### 12.1 Overview Tab 

- 4 metric cards: Total Conversations (30d), Persona Utilization %, Memory Growth (30d), Top Model  
- Each card: large number, label, trend indicator

### 12.2 Persona Performance Tab 

- Table: persona name, avatar, memory count, decisions count, skills count  
- Sortable columns

### 12.3 Model Usage Tab 

- Horizontal bar chart: model name, usage percentage, call count, estimated cost  
- Filterable by time range

### 12.4 Memory Health Tab 

- Grid of memory type counts: Total, Decisions, Facts, Preferences, Skills, Flagged  
- Flagged memories: conflicts, low confidence, stale

---

## PART 13: SETTINGS SCREEN 

### 13.1 Settings Architecture 

- 6 tabs: General, Models, API Keys, Types, Cascade, Learned Rules  
- All settings stored in user\_settings table (JSON per category) or dedicated tables

### 13.2 General Tab 

- Interface Mode toggle: Standard / Power User  
- Account info: name, email  
- Billing: current tier, BYOK status, credit balance, "Compare Plans" button → PricingModal  
- Feature gate indicators: which features are unlocked on current tier  
- Voice & Tone preferences  
- Storage usage indicator  
- Integrations count  
- System Intelligence toggles: Show routing notes, Show memory extraction  
- Onboarding replay button  
- Chat Cleanup & Organization: Auto-rename toggle, Suggest moves toggle, Scheduled cleanup toggle \+ frequency selector (Hourly/Daily/Weekly)

### 13.3 Models Tab 

- Role Assignment grid: roles (Conversational, Analytical, Creative, Code, Quick) × Primary model \+ Fallback model  
- Each role shows current model with provider tag  
- Click to change model from available models list  
- Model availability depends on tier \+ BYOK status

### 13.4 API Keys Tab 

- List of connected API keys: provider, key (masked), status, added date  
- Add key form: provider selector, key input, test connection button  
- OpenRouter key as primary  
- Individual provider keys (future): OpenAI, Anthropic, Google, etc.

### 13.5 Types Tab (Instance Types) 

- Manage Instance Type templates  
- Each type: name, description, default settings, default personas  
- Create/Edit/Delete types  
- Premium+ tier gate

### 13.6 Cascade Tab 

- Visual hierarchy: Platform Defaults → Global Instance Settings → Type Templates → Instance Settings  
- Shows which level each setting is defined at  
- Override indicators  
- Click to edit at any level

### 13.7 Learned Rules Tab 

- Auto-generated behavioral rules from user patterns  
- Instance-specific rules (e.g., "In Client Website Redesign, always use formal tone")  
- Active/Inactive toggle per rule  
- Filter: Active, Inactive, All  
- Database: instruction\_memory table (id, user\_id, instance\_id, rule, description, active, source, created\_at)

---

## PART 14: CIPHER ORCHESTRATION ENGINE 

### 14.1 Core Responsibilities 

- Message routing: determine which persona should respond  
- Memory arbitration: decide what gets stored, where, and for how long  
- Skill validation: check if a persona can handle a request  
- Safety enforcement: content filtering, boundary checking  
- Context assembly: build the optimal context window for each response  
- UI decisions: determine if a server-driven UI component should be rendered  
- Cross-persona coordination: manage multi-persona conversations

### 14.2 Implementation (Supabase Edge Functions) 

- `cipher-route`: Receives incoming message, analyzes intent, selects persona and model  
- `cipher-memory`: Post-response processing — extract memories, update CogniGraph  
- `cipher-cleanup`: Background job — suggest renames, moves, flag stale content  
- `cipher-health`: Monitor persona health metrics, detect conflicts

### 14.3 Routing Algorithm 

- Priority 1: Explicit @mention → route to named persona  
- Priority 2: Skill match → analyze message content, match to persona skills  
- Priority 3: Conversation context → continue with the persona who has been responding  
- Priority 4: Instance default → use the instance's primary persona  
- Priority 5: Global default → use user's default persona  
- Routing note generation: brief explanation stored with message metadata

### 14.4 Context Window Assembly 

- Checkpoint system: conversation segmented by topic/time/token-count checkpoints  
- For each response, Cipher assembles:  
  - System prompt (persona identity, boundaries, skills)  
  - Relevant CogniGraph nodes (semantic search)  
  - Recent conversation context (last N messages)  
  - Instance context (settings, forum state)  
  - Relevant checkpoint summaries (not raw history)  
- Token budget management: fit within model's context window

---

## PART 15: COGNIGRAPH MEMORY SYSTEM 

### 15.1 Data Model 

- **memory\_nodes** table: id, user\_id, persona\_id (nullable — null \= global), instance\_id (nullable), type (decision/fact/preference/skill/procedure), content, embedding (vector), confidence (0-1), source\_chat\_id, source\_message\_id, tags, layer (open/closed), active, created\_at, updated\_at, expires\_at  
- **memory\_edges** table: id, from\_node\_id, to\_node\_id, relationship (supports/contradicts/related/derived\_from), strength (0-1), created\_at  
- **memory\_checkpoints** table: id, chat\_id, summary, key\_topics, token\_count, checkpoint\_type (topic/time/token), created\_at

### 15.2 Memory Lifecycle 

- **Extraction:** After each AI response, Cipher analyzes for memorable content → creates candidate memory nodes  
- **Promotion:** Open (transient) → Closed (permanent) based on reinforcement, user confirmation, or repeated relevance  
- **Decay:** Unreinforced open-layer memories decay in confidence over time  
- **Conflict Detection:** New memories checked against existing for contradictions → flagged for user review  
- **Deletion:** User can manually delete; expired temporary memories auto-clean

### 15.3 Memory Types 

- **Decision:** A choice that was made ("We chose the asymmetric layout")  
- **Fact:** An objective piece of information ("Client's brand color is \#1a1a2e")  
- **Preference:** A user or instance preference ("Bob prefers concise responses")  
- **Skill:** A learned capability for a persona ("Can analyze competitor pricing")  
- **Procedure:** How to do something ("When uploading files, always rename to kebab-case")

### 15.4 Retrieval 

- Semantic search via pg\_vector embeddings  
- Scoping: persona-level → instance-level → global  
- Boost factor: instance relevance, recency, confidence  
- Token-budgeted retrieval: return top-K nodes that fit within allocated context tokens

---

## PART 16: MULTI-MODEL ROUTING 

### 16.1 Model Configuration 

- **model\_configs** table: id, user\_id, provider, model\_id, display\_name, api\_key\_id (nullable — uses BYOK or platform key), tier\_required, capabilities (JSON), created\_at  
- Available providers: OpenRouter (primary), direct APIs (OpenAI, Anthropic, Google — future)  
- BYOK: user provides their own OpenRouter API key → unlocks all models on that provider

### 16.2 Role-Based Assignment 

- 5 roles: Conversational, Analytical, Creative, Code, Quick  
- Each role maps to: primary\_model\_id \+ fallback\_model\_id  
- Assignment configurable at: global level, instance level, per-chat override  
- Cipher uses role assignment to select model per message

### 16.3 Streaming & Error Handling 

- Server-Sent Events for streaming responses  
- Automatic fallback: if primary model fails → try fallback → if both fail → return error with explanation  
- Rate limiting: per-user, per-model quotas based on tier  
- Token counting: track input/output tokens per message for billing and analytics

---

## PART 17: REAL-TIME & COLLABORATION 

### 17.1 Supabase Realtime Channels 

- `chat:{chat_id}`: New messages, typing indicators, participant changes  
- `instance:{instance_id}`: Task updates, file uploads, forum messages  
- `user:{user_id}`: Notifications, persona status changes, system alerts  
- `browser:{session_id}`: Tab changes, highlights, extracts

### 17.2 Typing Indicators 

- Show when a persona is "typing" (generating response)  
- Persona avatar \+ animated dots  
- Disappears when response starts streaming or generation completes

### 17.3 Presence (Future — Multi-User) 

- Show which personas are "active" in an instance  
- Eventually: show which human team members are online (enterprise)

---

## PART 18: SUPABASE DATABASE SCHEMA (Complete) 

### 18.1 Core Tables Summary 

This section will contain the complete SQL schema for every table referenced in the PRD, including:

- All tables with columns, types, constraints, defaults  
- Foreign key relationships  
- Indexes (including GiST for vector search)  
- Row Level Security (RLS) policies  
- Triggers (updated\_at auto-update, soft delete cascades)  
- Enums (user\_tier, persona\_status, message\_sender\_type, memory\_type, etc.)

### 18.2 RLS Policies 

- Every table: users can only access their own data (user\_id \= auth.uid())  
- Shared data: team/org-level access (enterprise, future)  
- Service role: Edge Functions can access all data for Cipher processing

### 18.3 Storage Buckets 

- `user-files`: User uploads, organized by user\_id  
- `persona-avatars`: Custom persona images (future)  
- `exports`: Generated export files (temporary, auto-cleanup)  
- `browser-extracts`: Saved page content and screenshots

---

## PART 19: API ROUTES & EDGE FUNCTIONS 

### 19.1 REST API (Supabase Auto-Generated) 

- Standard CRUD for all tables via Supabase client  
- PostgREST endpoints with RLS

### 19.2 Edge Functions 

- `POST /functions/v1/chat-send` — Process incoming message through Cipher, route, generate response  
- `POST /functions/v1/chat-stream` — SSE streaming endpoint for AI responses  
- `POST /functions/v1/cipher-analyze` — Background analysis for auto-rename, move suggestions  
- `POST /functions/v1/memory-extract` — Extract memories from message content  
- `POST /functions/v1/browser-proxy` — Server-side proxy for browser viewport  
- `POST /functions/v1/browser-extract` — Extract and store page content  
- `POST /functions/v1/search-unified` — Unified search across all content types  
- `POST /functions/v1/model-complete` — OpenRouter completion with fallback logic  
- `POST /functions/v1/export-generate` — Generate export files in requested format

### 19.3 Webhook Endpoints (Future) 

- Stripe webhook for subscription events  
- External integration webhooks (Google Drive, etc.)

---

## PART 20: FRONTEND COMPONENT ARCHITECTURE 

### 20.1 Page Structure (Next.js App Router) 

```
app/
  layout.tsx              — Root layout (auth check, providers, shell)
  page.tsx                — Home/Dashboard
  chat/page.tsx           — Chat screen
  search/page.tsx         — Search screen
  spaces/page.tsx         — Instances list
  spaces/[id]/page.tsx    — Instance detail
  files/page.tsx          — Global files
  people/page.tsx         — Personas list
  people/[id]/page.tsx    — Persona detail
  teams/page.tsx          — Teams list
  browser/page.tsx        — Browser workspace
  insights/page.tsx       — Analytics
  settings/page.tsx       — Settings
```

### 20.2 Shared Components 

- AppShell (sidebar \+ topbar \+ content area \+ right panel)  
- Sidebar, TopBar, CommandPalette, NotificationDropdown  
- Atoms: StatusDot, Avatar, MemoryTypeIcon  
- ChatDrawer, FilterBar, ParticipantsBar, PersonaSelector, ChatNavPanel  
- Modals: ExportModal, PricingModal, OnboardingModal, ShortcutsModal, CreatePersonaModal

### 20.3 State Management 

- Server state: Supabase client \+ React Query (TanStack Query) for caching  
- UI state: Zustand store for navigation, panel visibility, active selections  
- Real-time: Supabase Realtime subscriptions in providers  
- URL state: Next.js searchParams for shareable views (active tab, filter state)

---

## PART 21: EMPTY STATES & ERROR HANDLING 

### 21.1 Empty States (Every Screen) 

- Home: Getting started cards instead of stats  
- Chat: "Start your first conversation" with persona suggestions  
- Search: "Search across your entire workspace"  
- Spaces: "Create your first Instance" card  
- Files: "No files yet" with upload prompt  
- People: "Create your first Persona" with template suggestions  
- Teams: "Teams help personas work together" with explanation  
- Browser: "Browse the web with your personas alongside you"  
- Insights: "Analytics will appear as you use the platform"

### 21.2 Error States 

- Network error: retry banner  
- AI generation failure: error message with retry button, fallback model attempt  
- File upload failure: inline error with retry  
- Auth session expired: redirect to login with return URL  
- Rate limit hit: clear message with time until reset  
- Browser proxy failure: "This page couldn't be loaded. Try copying the URL to your browser."

### 21.3 Loading States 

- Skeleton screens for list/grid views  
- Typing indicator for AI responses  
- Progress bars for file uploads and exports  
- Spinner for browser page loads

---

## PART 22: TESTING & QUALITY ASSURANCE 

### 22.1 Test Categories 

- Unit tests: utility functions, state logic, routing algorithm  
- Integration tests: Supabase operations, Edge Function pipelines  
- Component tests: each screen renders correctly with mock data  
- E2E tests: critical user flows (sign up → create persona → create instance → chat → search)

### 22.2 Critical Flows to Test 

- Message send → Cipher route → model call → stream response → store  
- Memory extraction → conflict detection → storage  
- Browser proxy → DOM extraction → persona awareness → extract to instance  
- File upload → storage → file grid update  
- Tier upgrade → feature unlock → UI update

---

## PART 23: DEPLOYMENT & INFRASTRUCTURE 

### 23.1 Environments 

- Development: local Supabase \+ local Next.js  
- Staging: Supabase project (staging) \+ Vercel preview  
- Production: Supabase project (prod) \+ Vercel production

### 23.2 CI/CD 

- GitHub Actions: lint, type-check, test on PR  
- Vercel auto-deploy on merge to main  
- Supabase migrations via CLI

### 23.3 Monitoring 

- Vercel Analytics for frontend performance  
- Supabase Dashboard for database metrics  
- Custom logging in Edge Functions for Cipher decisions  
- Error tracking: Sentry

---

## PART 24: PHASED BUILD PLAN 

### Phase 1: Foundation (Weeks 1–3) 

- Supabase project setup, auth, database schema  
- Next.js app scaffold with App Router  
- Design system: Tailwind config, theme tokens, shared atoms  
- Application shell: sidebar, top bar, routing  
- Home screen with empty states

### Phase 2: Chat Core (Weeks 4–6) 

- Chat data model and CRUD  
- Message composer with model selector  
- AI response pipeline: OpenRouter integration, streaming  
- Chat list drawer  
- Basic Cipher routing (single persona)  
- Right panel (Nav, Filters, Pinned, People)

### Phase 3: Instances & Files (Weeks 7–9) 

- Instance CRUD with all 6 tabs  
- Instance settings with cascade  
- Global files screen with upload/management  
- Search screen (basic: chats \+ files)

### Phase 4: Personas & Memory (Weeks 10–12) 

- Persona CRUD with all 6 tabs  
- Persona creation wizard  
- CogniGraph: memory extraction, storage, retrieval  
- Multi-persona chat support  
- Cipher routing improvements (skill matching)

### Phase 5: Browser & Teams (Weeks 13–16) 

- Browser proxy architecture  
- Tab management system  
- 5 view modes with chat integration  
- DOM extraction and page awareness  
- Highlight and extract tools  
- Teams screen (list \+ basic detail)

### Phase 6: Polish & Analytics (Weeks 17–18) 

- Insights screen with all 4 tabs  
- Settings: all 6 tabs fully functional  
- Onboarding flow  
- Notification system  
- Export system  
- Empty states and error handling pass  
- Performance optimization

---

## APPENDICES 

### Appendix A: Prototype Component Map 

Complete cross-reference between PRD sections and prototype component labels (all 10 screens \+ shell).

### Appendix B: Keyboard Shortcuts Reference 

Full table of all shortcuts with context (global vs screen-specific).

### Appendix C: Tier Feature Matrix 

Complete grid: feature × tier showing limits and availability.

### Appendix D: Database Schema Diagram 

Entity-relationship diagram for all tables.

### Appendix E: Cipher Decision Tree 

Flowchart for message routing, memory extraction, and cleanup decisions.

### Appendix F: Browser Proxy Architecture Diagram 

Request flow: User click → proxy endpoint → fetch → rewrite → inject → render.

# PART 1: PRODUCT FOUNDATION 

---

## 1.1 Product Vision & Definition 

### One-Sentence Definition 

aiConnected OS is a fluid interaction platform where persistent AI personas act as believable collaborators — operating within explicit skill boundaries — while a continuous chat-based cognitive backbone preserves memory, context, and coordination across any activity the user chooses.

### The Three Problems Being Solved 

Current AI platforms share three fundamental failures that aiConnected is designed to address:

**Problem 1: Lack of Real Memory.** Every conversation starts from zero. Users must re-explain context, preferences, history, and decisions in every session. There is no continuity, no growth, no relationship. AI systems today are amnesiac by default, and the few "memory" features that exist are shallow keyword stores — not structured, retrievable, evolving knowledge.

**Problem 2: The Omniscience Illusion.** AI systems today present themselves as capable of everything. They never say "I don't know how to do that" or "that's outside my expertise." This creates unrealistic expectations, encourages over-reliance, and makes failures feel like betrayals. Users have no framework for understanding what an AI can and cannot do, because the AI itself never establishes one.

**Problem 3: Disposable Tool Mentality.** AI is treated as a utility — type a prompt, get a response, move on. There is no persistent identity, no relationship development, no accumulated capability. Every interaction is isolated. The AI never gets better at working with a specific user, and the user never builds a team they can rely on over time.

### What Makes aiConnected Different 

| Dimension | ChatGPT / Claude / Perplexity | aiConnected OS |
| :---- | :---- | :---- |
| Memory | Shallow, session-scoped or summarized | Structured knowledge graph (CogniGraph) with typed nodes, confidence scoring, cross-referencing, and lifecycle management |
| Identity | One generic assistant | Multiple persistent personas with finite skills, explicit boundaries, and evolving expertise |
| Capability framing | "I can do anything" | "Here is what I can do, and here is what I cannot" — personas refuse, escalate, or suggest specialists |
| Orchestration | None — user manages everything | Cipher layer invisibly routes, coordinates, validates, and governs all interactions |
| Workspace | Chat threads | Instances (project workspaces) with forums, tasks, files, personas, and settings inheritance |
| Interface | Static chat window | Fluid UI that adapts to activity — chat, browser co-working, split views, floating overlays |
| Continuity | Each session is independent | Every interaction contributes to a growing, persistent knowledge base that informs future interactions |

### Target Users 

**Primary (v1):** Individual professionals — developers, designers, writers, consultants, researchers, analysts — who use AI daily and are frustrated by its lack of continuity and structure.

**Secondary (v1):** Creators and small agency owners who need to manage multiple client projects with consistent AI assistance, organized workspaces, and persistent context.

**Tertiary (v2+):** Small teams who need shared AI workspaces. Enterprise organizations requiring compliance-safe AI with audit logs, SSO, role-based access, and data isolation.

---

## 1.2 Architecture Overview (Four Layers) 

aiConnected is built on a four-layer architecture where each layer has a clearly defined responsibility and communication boundary. This is the most important architectural concept in the system and must be understood before any implementation begins.

### Layer 1: Cipher (Invisible Orchestration Engine) 

**What it is:** The unrestricted internal cognition layer that governs, coordinates, and constrains all personas. Cipher is infrastructure, not a feature. It is never visible, addressable, or interactive to users.

**Responsibilities:**

- **Message routing:** Analyze incoming user messages and determine which persona should respond, using which model, with what context.  
- **Memory arbitration:** After each interaction, determine what should be remembered, where it should be stored, at what confidence level, and whether it conflicts with existing knowledge.  
- **Skill validation:** Before a persona responds, verify the request falls within its defined skill set. If not, trigger the appropriate fallback (refuse, suggest specialist, or escalate to user).  
- **Safety enforcement:** Content filtering, boundary checking, permission validation. Cipher enforces limits that personas themselves cannot override.  
- **Context assembly:** Build the optimal context window for each response — selecting relevant memories, conversation history, instance context, and persona identity from the available data.  
- **UI decisions:** Determine if a message should trigger a server-driven UI component (pricing table, task card, etc.) rather than plain text.  
- **Cross-persona coordination:** In multi-persona conversations, manage turn-taking, prevent overlapping responses, and ensure personas are aware of each other's contributions.

**Critical design principle:** Cipher can only act through personas. It can never bypass them. Even if Cipher "knows" something, that knowledge must be filtered through a persona's scope, skill limits, and learning consent before reaching the user. Cipher has no mouth — personas are the mouth.

**Implementation:** Supabase Edge Functions that run server-side. The user's client never communicates with Cipher directly. All Cipher logic executes between "user sends message" and "persona response begins streaming."

### Layer 2: CogniGraph (Structured Knowledge Graph Memory) 

**What it is:** A persistent, structured memory system that stores everything the platform learns — organized as a knowledge graph with typed nodes, weighted edges, and layered retrieval.

**Structure:**

- **Memory Nodes:** Individual units of knowledge, each with a type (decision, fact, preference, skill, procedure), confidence score, embedding vector, source reference, and lifecycle state.  
- **Memory Edges:** Relationships between nodes (supports, contradicts, related, derived\_from) with strength weights.  
- **Two Layers:**  
  - **Open Thinking Layer (OTL):** Transient, working memory. New observations land here first. Low confidence. Subject to decay if not reinforced.  
  - **Closed Thinking Layer (CTL):** Committed, permanent memory. Promoted from OTL through reinforcement, user confirmation, or repeated relevance. High confidence. The authoritative knowledge base.  
- **Checkpoints:** Conversations are segmented by topic, time, or token count. Each checkpoint produces a summary. This means the system never needs to load entire conversation histories — it loads checkpoint summaries and retrieves specific details on demand.

**Scope hierarchy:** Memory nodes can belong to a persona, an instance, or be global. Retrieval prioritizes narrower scope first (persona → instance → global) with boost factors for recency and confidence.

**Implementation:** PostgreSQL tables with pg\_vector extensions for embedding storage and semantic search. Memory extraction runs as a post-response Cipher task.

### Layer 3: Personas (Bounded AI Collaborators) 

**What they are:** Persistent AI identities with names, roles, personalities, finite skill sets, explicit boundaries, and human-like learning behavior. Each persona is a constrained projection of Cipher's capabilities — they know what they know and openly acknowledge what they don't.

**Key properties:**

- **Finite skills:** Each persona has a skill ceiling (default: 10 skills). Skills are explicit and enumerated. A persona cannot respond to requests outside its skills unless the user approves temporary or permanent learning.  
- **Boundaries:** Each persona has explicit "will do" / "won't do" lists and escalation rules. These are enforced by Cipher, not by the persona's own judgment.  
- **Identity persistence:** A persona's name, role, and core personality are fixed once created. Their knowledge grows through experience, but their identity doesn't drift.  
- **Learning:** Personas can learn new skills through three mechanisms: (1) temporary skill — task-scoped, auto-expires, no identity drift; (2) permanent skill — consumes a slot, changes future behavior, requires user approval; (3) new persona creation — when a task needs a clean specialist.  
- **Status:** Active (currently engaged), Idle (available but not in use), Sleeping (deactivated, preserves state).  
- **Mood:** A subtle indicator of the persona's recent workload and interaction quality. Not emotional — operational. Helps users understand when a persona might be overextended.

**Communication rules:** Only personas speak to users. Personas are aware of other personas in a conversation but do not know Cipher exists. Personas can question each other, build on ideas, and respectfully disagree.

**Implementation:** Database-backed entities with Supabase tables. Persona behavior is constructed at inference time by Cipher assembling the persona's identity, skills, boundaries, and relevant memories into a system prompt.

### Layer 4: Fluid UI (Adaptive Interface) 

**What it is:** An interface that reshapes itself around the user's current activity. Chat is the persistent spine — it is never destroyed, only resized or minimized. Activities (browsing, writing, researching, managing) are rendered as views around the chat.

**Core principles:**

- **Chat as spine:** Every interaction flows through chat. Whether the user is browsing a website, managing tasks, or reviewing files, the conversation continues. There is no "leaving chat" to do something else.  
- **Progressive disclosure:** The interface starts simple and reveals complexity as the user needs it. Standard mode hides advanced features. Power mode exposes everything. The system suggests feature discovery naturally.  
- **No hard modes:** Switching between browsing, chatting, and managing is fluid. The UI layout changes, but the session state does not. Changing the browser view from "float" to "sidebar" to "50/50" is a layout preference, not a mode switch.  
- **Server-driven components:** AI responses can include structured UI components (pricing tables, task cards, comparison grids) rendered inline in the chat. The frontend is a renderer; Cipher decides what to render.

**Implementation:** Next.js with dynamic layouts. State managed by Zustand (UI state) and React Query (server state). Layout transitions use CSS transitions (0.15s–0.2s ease).

### Layer Communication Diagram 

```
┌─────────────────────────────────────────────┐
│                   USER                       │
│         (sees only Fluid UI + Personas)      │
└──────────────┬──────────────────┬────────────┘
               │ interacts with   │ sees responses from
               ▼                  ▼
┌──────────────────────────────────────────────┐
│              FLUID UI (Layer 4)              │
│   Chat · Browser · Instances · Files · etc.  │
└──────────────┬──────────────────┬────────────┘
               │ sends messages   │ renders responses
               ▼                  ▲
┌──────────────────────────────────────────────┐
│            PERSONAS (Layer 3)                │
│   Sally · Sam · Nora · Dev · [user-created]  │
│   Bounded skills · Explicit limits · Memory  │
└──────────────┬──────────────────┬────────────┘
               │ governed by      │ informed by
               ▼                  ▼
┌──────────────────────────────────────────────┐
│            CIPHER (Layer 1)                  │
│   Routing · Validation · Safety · Assembly   │
│   INVISIBLE — never user-facing              │
├──────────────────────────────────────────────┤
│          COGNIGRAPH (Layer 2)                │
│   Memory Nodes · Edges · Checkpoints         │
│   OTL (transient) → CTL (permanent)          │
│   Semantic search · Scoped retrieval          │
└──────────────────────────────────────────────┘
```

**Key constraint:** Information flows upward through personas. Cipher and CogniGraph never communicate directly with the user. The user never knows Cipher exists (unless they enable "show routing notes" in Settings, which displays subtle inline annotations).

---

## 1.3 Technology Stack 

Every technology choice is listed here with the specific version, purpose, and configuration requirements. Claude Code should install and configure exactly these dependencies.

### Frontend 

| Technology | Version | Purpose |
| :---- | :---- | :---- |
| Next.js | 14.x+ (App Router) | Framework. All pages use the App Router (`app/` directory). No Pages Router. |
| React | 18.x+ | UI library. Use Server Components by default, Client Components only where interactivity requires it. |
| TypeScript | 5.x+ | Type safety for all files. Strict mode enabled. No `any` types except in clearly marked escape hatches. |
| Tailwind CSS | 3.x+ | Utility-first styling. Custom theme tokens defined in `tailwind.config.ts`. No separate CSS files except for global resets. |
| shadcn/ui | latest | Component library. Install components individually as needed (Button, Dialog, DropdownMenu, Input, etc.). Do not install the entire library. |
| Lucide React | 0.263+ | Icon library. Every icon used in the prototype is from Lucide. See Section 1.4 for the complete icon inventory. |
| Zustand | 4.x+ | Client-side state management for UI state (sidebar open/closed, active panel, modal visibility, etc.). |
| TanStack Query | 5.x+ | Server state management. All Supabase data fetching uses React Query for caching, optimistic updates, and refetching. |
| DM Sans | Google Fonts | Primary typeface. Loaded via `next/font/google`. Weights: 300, 350, 400, 450, 500, 600, 700\. |

### Backend (Supabase) 

| Technology | Purpose |
| :---- | :---- |
| Supabase PostgreSQL | Primary database. All application data. pg\_vector extension for embeddings. Full-text search via tsvector. |
| Supabase Auth | Authentication. Email/password (v1), Google OAuth (v1), GitHub OAuth (v1). |
| Supabase Realtime | WebSocket subscriptions for live updates. Channels for chat messages, notifications, presence. |
| Supabase Edge Functions | Server-side logic. Cipher routing, AI completions, browser proxy, memory extraction. Deno runtime. |
| Supabase Storage | File storage. User uploads, generated files, exports, browser screenshots. |
| Supabase Row Level Security | Data isolation. Every table enforces user-scoped access via RLS policies. |

### AI & Model Access 

| Technology | Purpose |
| :---- | :---- |
| OpenRouter API | Primary AI model gateway. Routes to OpenAI, Anthropic, Google, Meta, Mistral, etc. Single API key, multiple models. |
| BYOK (Bring Your Own Key) | Users can provide their own OpenRouter API key. Stored encrypted in database. All model calls route through their key when present. |

### Deployment 

| Technology | Purpose |
| :---- | :---- |
| Vercel | Frontend hosting. Auto-deploy from GitHub. Preview deployments for PRs. Edge runtime for API routes that don't need Supabase Edge Functions. |
| GitHub | Source control. Main branch \= production. Feature branches with PR workflow. |
| Supabase CLI | Database migrations. `supabase db push` for schema changes. `supabase functions deploy` for Edge Functions. |

### Development Tools 

| Technology | Purpose |
| :---- | :---- |
| ESLint | Linting. Next.js default config \+ strict TypeScript rules. |
| Prettier | Code formatting. Default config with tailwindcss plugin for class sorting. |
| Vitest | Unit and integration testing. |
| Playwright | E2E testing for critical flows. |

### Package Installation Commands 

```shell
# Create Next.js project
npx create-next-app@latest aiconnected-os --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

# Core dependencies
npm install @supabase/supabase-js @supabase/ssr
npm install @tanstack/react-query
npm install zustand
npm install lucide-react
npm install openai  # OpenRouter uses OpenAI-compatible API

# shadcn/ui setup
npx shadcn-ui@latest init
# Then install individual components as needed:
npx shadcn-ui@latest add button dialog dropdown-menu input textarea tabs tooltip

# Development
npm install -D vitest @testing-library/react playwright
```

---

## 1.4 Design System & Theming 

The design system is defined by exact token values extracted from the interactive prototype. Claude Code must implement these tokens exactly — they represent deliberate design decisions, not approximations.

### Brand Identity 

- **Product name:** aiConnected (camelCase, lowercase 'ai')  
- **Logo mark:** Sparkles icon (from Lucide) in white, displayed in sidebar header  
- **Primary typeface:** DM Sans (Google Fonts)  
- **Brand palette:** Navy backgrounds with blue accent. The overall aesthetic is minimal, professional, and typographically driven — never "AI-looking" with gradients, glowing orbs, or emoji-heavy decoration.

### Color Tokens 

All colors are defined as a flat token object. The application supports two themes: `light` and `dark`. The sidebar uses its own color scale regardless of active theme.

#### Light Theme 

```ts
const lightTheme = {
  // Backgrounds
  bg: "#fafafa",              // Page background
  surface: "#ffffff",          // Card/panel background
  surfaceAlt: "#f5f5f5",      // Secondary surface (inputs, chips, hover states)

  // Text
  text: "#0a0a0a",            // Primary text
  textSec: "#525252",         // Secondary text (message bodies)
  textMuted: "#a3a3a3",       // Muted text (labels, hints)
  textFaint: "#d4d4d4",       // Faint text (timestamps, disabled)

  // Borders
  border: "#e5e5e5",          // Standard borders
  borderSubtle: "#f0f0f0",    // Subtle dividers

  // Accent
  accent: "#0a0a0a",          // Primary accent (buttons, active states) — black in light mode
  accentText: "#fafafa",      // Text on accent backgrounds

  // Status dots
  dot: {
    active: "#22c55e",        // Green — persona is active
    idle: "#f59e0b",          // Amber — persona is idle
    sleeping: "#cbd5e1",      // Slate — persona is sleeping
  },

  // Specialized
  inputBg: "#f5f5f5",         // Input field background
  pinBg: "#fffbeb",           // Pinned message background (warm yellow)
  pinBorder: "#fde68a",       // Pinned message left border
};
```

#### Dark Theme 

```ts
const darkTheme = {
  bg: "#0a0a0a",
  surface: "#141414",
  surfaceAlt: "#1a1a1a",

  text: "#fafafa",
  textSec: "#a3a3a3",
  textMuted: "#666666",
  textFaint: "#333333",

  border: "#1e1e1e",
  borderSubtle: "#181818",

  accent: "#fafafa",          // White in dark mode
  accentText: "#0a0a0a",

  dot: {
    active: "#4ade80",
    idle: "#fbbf24",
    sleeping: "#475569",
  },

  inputBg: "#1a1a1a",
  pinBg: "#1a1700",
  pinBorder: "#3d3400",
};
```

#### Sidebar Tokens (Always Dark) 

The sidebar uses a dark navy/charcoal palette in both light and dark modes. This creates a persistent visual anchor on the left side of the screen.

```ts
// Light mode sidebar
const lightSidebar = {
  bg: "#111111",
  bgHover: "#1a1a1a",
  bgActive: "#222222",
  text: "#e5e5e5",
  textMuted: "#777777",
  textFaint: "#444444",
  border: "#282828",
};

// Dark mode sidebar
const darkSidebar = {
  bg: "#0a0a0a",
  bgHover: "#141414",
  bgActive: "#1a1a1a",
  text: "#e5e5e5",
  textMuted: "#666666",
  textFaint: "#333333",
  border: "#1e1e1e",
};
```

### Tailwind Configuration 

```ts
// tailwind.config.ts
import type { Config } from "tailwindcss";

const config: Config = {
  darkMode: "class",
  content: ["./src/**/*.{ts,tsx}"],
  theme: {
    extend: {
      fontFamily: {
        sans: ["var(--font-dm-sans)", "-apple-system", "BlinkMacSystemFont", "sans-serif"],
      },
      colors: {
        brand: {
          navy: "#021220",
          navyLight: "#031c33",
          blue: "#2e95f3",
        },
        status: {
          active: "#22c55e",
          idle: "#f59e0b",
          sleeping: "#cbd5e1",
        },
      },
      fontSize: {
        // Custom sizes used in prototype (px values → rem)
        "2xs": ["0.5625rem", { lineHeight: "1" }],     // 9px
        "xs": ["0.625rem", { lineHeight: "1.2" }],      // 10px
        "sm": ["0.6875rem", { lineHeight: "1.4" }],     // 11px
        "base": ["0.75rem", { lineHeight: "1.5" }],     // 12px
        "md": ["0.8125rem", { lineHeight: "1.5" }],     // 13px
        "lg": ["0.875rem", { lineHeight: "1.6" }],      // 14px
        "xl": ["1rem", { lineHeight: "1.5" }],           // 16px
        "2xl": ["1.25rem", { lineHeight: "1.3" }],      // 20px
        "3xl": ["1.375rem", { lineHeight: "1.3" }],     // 22px
        "4xl": ["1.5rem", { lineHeight: "1.2" }],       // 24px
        "5xl": ["1.75rem", { lineHeight: "1.2" }],      // 28px
        "hero": ["2.25rem", { lineHeight: "1" }],        // 36px (stat numbers)
      },
      letterSpacing: {
        tighter: "-0.04em",
        tight: "-0.03em",
        snug: "-0.02em",
        normal: "0",
        wide: "0.02em",
        wider: "0.06em",
        widest: "0.1em",
      },
      borderRadius: {
        sm: "4px",
        DEFAULT: "6px",
        md: "8px",
        lg: "10px",
        xl: "12px",
        "2xl": "16px",
        "3xl": "20px",
        full: "9999px",
      },
      transitionDuration: {
        fast: "150ms",
        DEFAULT: "200ms",
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
};

export default config;
```

### Typography Scale 

The prototype uses a consistent typography system. These are the exact patterns:

| Element | Size | Weight | Color Token | Letter Spacing |
| :---- | :---- | :---- | :---- | :---- |
| Page title | 28px | 300 | text | \-0.03em |
| Section title | 22px | 300 | text | \-0.03em |
| Instance/persona name | 24px | 300 | text | \-0.02em |
| Card title | 15px | 450 | text | — |
| Body text (messages) | 14px | 350 | textSec | — |
| Label text | 13px | 400–500 | text or textMuted | — |
| Small text | 12px | 350–400 | textMuted | 0.02em |
| Caption text | 11px | 300–400 | textFaint | — |
| Micro text | 10px | 300 | textFaint | — |
| Section header (uppercase) | 10–11px | 500 | textFaint | 0.06em–0.1em |
| Stat numbers | 36px | 200 | text | \-0.04em |
| Chip/badge text | 9–10px | 350–400 | varies | — |

### Font Weight Conventions 

| Weight | Usage |
| :---- | :---- |
| 200 | Large stat numbers only |
| 300 | Page titles, subtitles, timestamps, descriptive text |
| 350 | Message body, secondary content, input text |
| 400 | Standard labels, chips, navigation items |
| 450 | Card titles, section names, emphasis within body |
| 500 | Active states, section headers, persona names |
| 600 | Brand name in sidebar, bold emphasis |
| 700 | Favicon letters, checkmarks, strong emphasis (rare) |

### Spacing System 

The prototype uses a consistent spacing approach based on multiples of 4px:

| Context | Value |
| :---- | :---- |
| Page padding (desktop) | 48px horizontal, 40px vertical |
| Page padding (mobile) | 20px horizontal, 24px vertical |
| Card internal padding | 14px–20px |
| List item padding | 9px–16px vertical, 14px–16px horizontal |
| Gap between cards | 10px–12px |
| Gap between sections | 20px–28px |
| Filter chip gap | 4px–6px |
| Button padding | 3px–8px vertical, 7px–18px horizontal |
| Input padding | 6px–9px vertical, 10px–12px horizontal |
| Sidebar item padding | 10px vertical, 12px horizontal (expanded), centered (collapsed) |

### Border Radius Conventions 

| Element | Radius |
| :---- | :---- |
| Page-level modals | 16px–20px |
| Cards | 10px–12px |
| Input fields | 8px–12px |
| Buttons | 6px–8px |
| Chips/badges | 16px–20px (pill shape) |
| Avatars | 50% (full circle) |
| Dropdown menus | 10px–12px |
| Tab strips | 20px (pill tabs) |
| Tooltips | 4px–6px |

### Shared Atomic Components 

These three components are used across every screen and must be implemented as reusable primitives:

#### StatusDot 

A circular indicator showing persona status.

```ts
interface StatusDotProps {
  status: "active" | "idle" | "sleeping";
  size?: number; // default 7
}
```

Renders as a colored circle using the `dot` theme tokens.

#### Avatar 

An initial-based circular avatar. No images in v1 — always displays the first character of the name.

```ts
interface AvatarProps {
  name: string;
  size?: number;      // default 32
  style?: CSSProperties;
}
```

Renders as a circle with `surfaceAlt` background, centered initial character at 36% of the circle diameter, `textMuted` color, weight 500\.

#### MemoryTypeIcon 

An icon indicating the type of a memory node.

```ts
interface MemoryTypeIconProps {
  type: "decision" | "fact" | "preference" | "skill";
}
```

Maps to: decision → `GitBranch`, fact → `Brain`, preference → `Heart`, skill → `Zap`. All rendered at 13px, `textMuted` color, strokeWidth 1.5. Falls back to `Hash` for unknown types.

### Icon Inventory 

Every icon used in the prototype comes from Lucide React. The complete list:

```
MessageSquare, Search, LayoutGrid, Users, Settings, ChevronRight, ChevronDown,
Mic, Paperclip, ArrowUp, Pin, Plus, Moon, Sun, Menu, X, User,
CreditCard, Volume2, HardDrive, Globe, Cpu, Link2, Zap, Clock,
Brain, GitBranch, Heart, ExternalLink, Activity, Shield, RotateCcw,
Eye, Home, Bot, BarChart3, Sparkles, Hash, MapPin, UserCircle,
Folder, PanelRightOpen, PanelRightClose, Command, Copy, MoreHorizontal,
Square, RefreshCw, GitFork, Filter, ImageIcon, Send, Inbox,
AtSign, UserPlus, FileText, Download, Trash2, Archive, Edit3,
Image, FileCode, FilePlus, Upload, EyeOff, FolderOpen, Layers,
Sliders, ArrowRight, ToggleLeft, ToggleRight, Info, Bell,
Bookmark, History, FileDown, AlertCircle,
ArrowLeft, Maximize2, Minimize2, Columns, MousePointer2, Compass, ScanLine, CircleDot
```

### Responsive Design 

| Breakpoint | Name | Behavior |
| :---- | :---- | :---- |
| \< 768px | Mobile | Sidebar becomes hamburger overlay. Panels become full-screen sheets. Grids collapse to 1–2 columns. Page padding reduces. Touch targets enlarge to 44px minimum. |
| 768px–1024px | Tablet | Sidebar collapsed by default. Right panel as overlay. Grids adjust to 2–3 columns. |
| \> 1024px | Desktop | Full layout with collapsible sidebar, optional right panel, multi-column grids. |

Mobile detection in the prototype:

```ts
const [mobile, setMobile] = useState(false);
useEffect(() => {
  const check = () => setMobile(window.innerWidth < 768);
  check();
  window.addEventListener("resize", check);
  return () => window.removeEventListener("resize", check);
}, []);
```

In production, use a combination of CSS media queries (Tailwind responsive prefixes) and a React hook for JavaScript-dependent layout decisions.

### Animation Conventions 

| Transition | Duration | Easing | Usage |
| :---- | :---- | :---- | :---- |
| Standard | 150ms | ease | Hover states, color changes, opacity changes, icon rotations |
| Layout | 200ms | ease | Sidebar expand/collapse, panel open/close, view mode switches |
| None | 0ms | — | Click feedback, instant toggles |

**Rules:**

- No transitions exceed 200ms. The interface should feel instant, not animated.  
- Sidebar width transition: `transition: width 0.2s ease`  
- Chevron rotation on expand/collapse: `transition: transform 0.15s`  
- Panel slide-in: CSS transform with 200ms ease  
- No spring physics, no bounce, no overshoot. Professional and restrained.

---

## 1.5 Project Structure 

The recommended file structure for Claude Code to scaffold:

```
aiconnected-os/
├── src/
│   ├── app/                          # Next.js App Router pages
│   │   ├── layout.tsx                # Root layout (fonts, providers, auth check)
│   │   ├── page.tsx                  # Home / Dashboard
│   │   ├── chat/page.tsx
│   │   ├── search/page.tsx
│   │   ├── spaces/
│   │   │   ├── page.tsx              # Instance list
│   │   │   └── [id]/page.tsx         # Instance detail
│   │   ├── files/page.tsx
│   │   ├── people/
│   │   │   ├── page.tsx              # Persona list
│   │   │   └── [id]/page.tsx         # Persona detail
│   │   ├── teams/page.tsx
│   │   ├── browser/page.tsx
│   │   ├── insights/page.tsx
│   │   ├── settings/page.tsx
│   │   └── auth/
│   │       ├── login/page.tsx
│   │       ├── signup/page.tsx
│   │       └── callback/route.ts     # OAuth callback handler
│   │
│   ├── components/
│   │   ├── shell/                    # Application shell
│   │   │   ├── AppShell.tsx          # Main layout wrapper
│   │   │   ├── Sidebar.tsx
│   │   │   ├── TopBar.tsx
│   │   │   ├── CommandPalette.tsx
│   │   │   └── MobileOverlay.tsx
│   │   ├── atoms/                    # Shared primitives
│   │   │   ├── Avatar.tsx
│   │   │   ├── StatusDot.tsx
│   │   │   ├── MemoryTypeIcon.tsx
│   │   │   └── FilterChip.tsx
│   │   ├── chat/                     # Chat-specific components
│   │   │   ├── ChatDrawer.tsx
│   │   │   ├── MessageList.tsx
│   │   │   ├── MessageComposer.tsx
│   │   │   ├── PersonaSelector.tsx
│   │   │   ├── ModelSelector.tsx
│   │   │   ├── ChatNavPanel.tsx
│   │   │   └── RightPanel.tsx
│   │   ├── browser/                  # Browser workspace components
│   │   │   ├── BrowserViewport.tsx
│   │   │   ├── FloatingNavBar.tsx
│   │   │   ├── TabStrip.tsx
│   │   │   ├── ViewSwitcher.tsx
│   │   │   ├── FloatingPersonaBar.tsx
│   │   │   └── BrowserChatPanel.tsx
│   │   ├── modals/                   # All modal components
│   │   │   ├── ExportModal.tsx
│   │   │   ├── PricingModal.tsx
│   │   │   ├── OnboardingModal.tsx
│   │   │   ├── ShortcutsModal.tsx
│   │   │   └── CreatePersonaModal.tsx
│   │   └── shared/                   # Cross-screen components
│   │       ├── NotificationDropdown.tsx
│   │       ├── NotificationHistory.tsx
│   │       └── EmptyState.tsx
│   │
│   ├── lib/
│   │   ├── supabase/
│   │   │   ├── client.ts             # Browser Supabase client
│   │   │   ├── server.ts             # Server Supabase client
│   │   │   ├── middleware.ts          # Auth middleware
│   │   │   └── types.ts              # Generated database types
│   │   ├── openrouter/
│   │   │   ├── client.ts             # OpenRouter API client
│   │   │   └── models.ts             # Available model definitions
│   │   ├── cipher/
│   │   │   ├── router.ts             # Message routing logic
│   │   │   ├── context.ts            # Context window assembly
│   │   │   └── memory.ts             # Memory extraction logic
│   │   └── utils/
│   │       ├── tier-gates.ts         # Feature gating per tier
│   │       ├── formatting.ts         # Date, number, text formatting
│   │       └── constants.ts          # App-wide constants
│   │
│   ├── stores/
│   │   ├── ui-store.ts               # Zustand: UI state (panels, modals, sidebar)
│   │   ├── navigation-store.ts       # Zustand: Screen, active entity selection
│   │   └── browser-store.ts          # Zustand: Browser-specific state
│   │
│   ├── hooks/
│   │   ├── use-chat.ts               # Chat operations (send, stream, filter)
│   │   ├── use-personas.ts           # Persona CRUD and queries
│   │   ├── use-instances.ts          # Instance CRUD and queries
│   │   ├── use-realtime.ts           # Supabase Realtime subscriptions
│   │   ├── use-mobile.ts             # Responsive breakpoint detection
│   │   └── use-theme.ts              # Theme management
│   │
│   └── types/
│       ├── database.ts               # Supabase-generated types
│       ├── chat.ts                   # Chat/message types
│       ├── persona.ts                # Persona types
│       ├── instance.ts               # Instance types
│       ├── memory.ts                 # CogniGraph types
│       └── browser.ts                # Browser session types
│
├── supabase/
│   ├── migrations/                   # SQL migration files (ordered)
│   ├── functions/                    # Edge Functions (Deno)
│   │   ├── chat-send/index.ts
│   │   ├── chat-stream/index.ts
│   │   ├── cipher-analyze/index.ts
│   │   ├── memory-extract/index.ts
│   │   ├── browser-proxy/index.ts
│   │   ├── search-unified/index.ts
│   │   └── model-complete/index.ts
│   └── seed.sql                      # Development seed data
│
├── public/
│   └── (static assets)
│
├── tailwind.config.ts
├── tsconfig.json
├── next.config.ts
└── package.json
```

This structure separates concerns cleanly: pages handle routing and layout, components handle rendering, lib handles business logic and external service clients, stores handle client state, hooks provide reusable data access patterns, and types ensure type safety throughout.

---

*End of Part 1\. Proceed to Part 2: Authentication & User Management.*

# PART 2: AUTHENTICATION & USER MANAGEMENT 

---

## 2.1 Authentication 

### Provider Configuration 

aiConnected uses Supabase Auth as the sole authentication provider. All authentication flows are server-side validated, and all protected pages require an active session.

**v1 Auth Methods:**

| Method | Provider | Notes |
| :---- | :---- | :---- |
| Email \+ Password | Supabase Auth (built-in) | Primary method. Minimum 8-character password. |
| Google OAuth | Supabase Auth (Google provider) | One-click sign-in. Auto-creates profile from Google account data. |
| GitHub OAuth | Supabase Auth (GitHub provider) | For developer-oriented users. |

**v2 Auth Methods (post-launch):**

| Method | Provider | Notes |
| :---- | :---- | :---- |
| Apple Sign-In | Supabase Auth (Apple provider) | Required for iOS App Store if a native app is built. |
| Microsoft OAuth | Supabase Auth (Azure AD provider) | For enterprise users. |
| SAML SSO | Supabase Auth (SAML provider) | Enterprise tier only. Per-organization identity provider. |

### Supabase Auth Configuration 

```ts
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";

export const createClient = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
```

```ts
// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export const createClient = () => {
  const cookieStore = cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return cookieStore.getAll(); },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          );
        },
      },
    }
  );
};
```

### Authentication Flows 

#### Sign Up (Email \+ Password) 

1. User navigates to `/auth/signup`  
2. User enters display name, email, and password  
3. Client calls `supabase.auth.signUp({ email, password, options: { data: { display_name } } })`  
4. Supabase sends confirmation email  
5. User clicks confirmation link → redirects to `/auth/callback`  
6. Callback handler exchanges code for session  
7. `after_sign_up` trigger fires → creates row in `profiles` table with defaults  
8. User is redirected to `/` (Home) with onboarding modal shown

#### Sign In (Email \+ Password) 

1. User navigates to `/auth/login`  
2. User enters email and password  
3. Client calls `supabase.auth.signInWithPassword({ email, password })`  
4. On success → redirect to `/` (Home)  
5. On failure → display error message inline ("Invalid email or password")

#### Sign In (OAuth) 

1. User clicks "Continue with Google" or "Continue with GitHub"  
2. Client calls `supabase.auth.signInWithOAuth({ provider, options: { redirectTo: '/auth/callback' } })`  
3. User completes OAuth flow in provider's window  
4. Redirected to `/auth/callback` with auth code  
5. Callback handler exchanges code for session  
6. If first sign-in → `after_sign_up` trigger creates `profiles` row using OAuth metadata (name, avatar)  
7. Redirect to `/` (Home)

#### OAuth Callback Handler 

```ts
// src/app/auth/callback/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");
  const next = searchParams.get("next") ?? "/";

  if (code) {
    const supabase = createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) {
      return NextResponse.redirect(`${origin}${next}`);
    }
  }

  return NextResponse.redirect(`${origin}/auth/login?error=auth_failed`);
}
```

#### Password Reset 

1. User clicks "Forgot password?" on login page  
2. User enters email address  
3. Client calls `supabase.auth.resetPasswordForEmail(email, { redirectTo: '/auth/callback?next=/auth/reset-password' })`  
4. User receives email with reset link  
5. Clicking link → callback handler → redirected to `/auth/reset-password`  
6. User enters new password  
7. Client calls `supabase.auth.updateUser({ password: newPassword })`  
8. On success → redirect to `/` with confirmation toast

#### Sign Out 

1. User clicks sign-out in settings or profile menu  
2. Client calls `supabase.auth.signOut()`  
3. All local state is cleared  
4. Redirect to `/auth/login`

### Session Management 

- Sessions are managed entirely by Supabase Auth using HTTP-only cookies  
- Token refresh is automatic via the `@supabase/ssr` middleware  
- Session validity: 1 hour access token, 7-day refresh token (Supabase defaults)  
- On session expiration with no valid refresh token → redirect to `/auth/login` with a return URL parameter so the user returns to where they were

### Auth Middleware 

```ts
// src/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return request.cookies.getAll(); },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => {
            request.cookies.set(name, value);
            supabaseResponse.cookies.set(name, value, options);
          });
        },
      },
    }
  );

  const { data: { user } } = await supabase.auth.getUser();

  // Protected routes: redirect to login if no user
  const publicPaths = ["/auth/login", "/auth/signup", "/auth/callback"];
  if (!user && !publicPaths.some(p => request.nextUrl.pathname.startsWith(p))) {
    const url = request.nextUrl.clone();
    url.pathname = "/auth/login";
    url.searchParams.set("next", request.nextUrl.pathname);
    return NextResponse.redirect(url);
  }

  // Logged-in users visiting auth pages → redirect to home
  if (user && publicPaths.some(p => request.nextUrl.pathname.startsWith(p))) {
    return NextResponse.redirect(new URL("/", request.url));
  }

  return supabaseResponse;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
};
```

### Auth UI Pages 

#### Login Page (`/auth/login`) 

- Centered card (420px max-width) on minimal background  
- aiConnected logo (Sparkles icon) \+ "Sign in to aiConnected" heading  
- Email input field  
- Password input field with show/hide toggle  
- "Forgot password?" link (opens password reset inline or separate page)  
- "Sign In" primary button  
- Divider: "or"  
- "Continue with Google" button (Google icon \+ text)  
- "Continue with GitHub" button (GitHub icon \+ text)  
- Footer: "Don't have an account? Sign up" linking to `/auth/signup`  
- Error messages appear inline below the relevant field or as a banner above the form

#### Sign Up Page (`/auth/signup`) 

- Same centered card layout  
- Display Name input  
- Email input  
- Password input (with strength indicator: weak/medium/strong)  
- "Create Account" primary button  
- Same OAuth buttons  
- Footer: "Already have an account? Sign in"  
- After submission: confirmation message "Check your email to verify your account"

#### Password Reset Page (`/auth/reset-password`) 

- Centered card  
- "Set new password" heading  
- New password input (with strength indicator)  
- Confirm password input  
- "Update Password" button  
- Success state: "Password updated. Redirecting..." → auto-redirect to Home after 2 seconds

---

## 2.2 User Profile 

### Database Schema 

The `profiles` table extends Supabase's `auth.users` with application-specific data. It is automatically created via a database trigger when a new user signs up.

```sql
-- Profiles table
CREATE TABLE public.profiles (
  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  display_name TEXT NOT NULL DEFAULT '',
  avatar_url TEXT,
  tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'plus', 'premium', 'pro')),
  preferences JSONB NOT NULL DEFAULT '{
    "theme": "system",
    "interface_mode": "standard",
    "default_model": null,
    "show_routing_notes": true,
    "show_memory_extraction": true,
    "notifications": {
      "persona_updates": true,
      "system_alerts": true,
      "chat_mentions": true,
      "weekly_summary": true
    },
    "voice_tone": "professional",
    "auto_rename": true,
    "suggest_moves": true,
    "scheduled_cleanup": false,
    "cleanup_frequency": "daily"
  }'::jsonb,
  onboarding_completed BOOLEAN NOT NULL DEFAULT false,
  credits INTEGER NOT NULL DEFAULT 100,
  storage_used_bytes BIGINT NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Auto-update updated_at
CREATE TRIGGER set_profiles_updated_at
  BEFORE UPDATE ON public.profiles
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();

-- Auto-create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.profiles (id, display_name, avatar_url)
  VALUES (
    NEW.id,
    COALESCE(NEW.raw_user_meta_data ->> 'display_name', NEW.raw_user_meta_data ->> 'full_name', NEW.raw_user_meta_data ->> 'name', ''),
    COALESCE(NEW.raw_user_meta_data ->> 'avatar_url', NULL)
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

-- RLS
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view own profile"
  ON public.profiles FOR SELECT
  USING (auth.uid() = id);

CREATE POLICY "Users can update own profile"
  ON public.profiles FOR UPDATE
  USING (auth.uid() = id);

-- Indexes
CREATE INDEX idx_profiles_tier ON public.profiles(tier);
```

### Shared Utility Function 

```sql
-- Reusable updated_at trigger function (used by all tables)
CREATE OR REPLACE FUNCTION public.handle_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
```

### Preferences JSON Schema 

The `preferences` JSONB column holds all user-configurable settings. This is the complete shape:

```ts
interface UserPreferences {
  theme: "light" | "dark" | "system";
  interface_mode: "standard" | "power";
  default_model: string | null;           // OpenRouter model ID
  show_routing_notes: boolean;            // Show Cipher routing annotations in chat
  show_memory_extraction: boolean;        // Show "1 memory saved" indicators
  notifications: {
    persona_updates: boolean;             // Activity from assigned personas
    system_alerts: boolean;               // Usage alerts, tier limits
    chat_mentions: boolean;               // @mentions and completions
    weekly_summary: boolean;              // Weekly usage digest email
  };
  voice_tone: string;                     // "professional" | "warm" | "casual" | "direct" | custom
  auto_rename: boolean;                   // Auto-suggest chat renames
  suggest_moves: boolean;                 // Suggest moving chats to instances
  scheduled_cleanup: boolean;             // Background scan for unorganized chats
  cleanup_frequency: "hourly" | "daily" | "weekly";
}
```

### Profile Display in UI 

The user's profile appears in two places:

1. **Sidebar footer:** Small avatar circle (28px) with initial, display name truncated to fit, tier badge (e.g., "Pro" pill). Clicking opens a compact menu with: Profile settings, Theme toggle, Sign out.  
     
2. **Settings → General tab → Account row:** Shows display name and email. Clicking opens inline editing for display name. Email changes require re-verification via Supabase Auth.

---

## 2.3 Pricing Tiers & Feature Gating 

### Tier Definitions 

aiConnected uses four pricing tiers. Every feature in the platform is either universally available or gated to a specific tier. The BYOK system allows users on any tier to unlock unlimited AI chat by providing their own OpenRouter API key.

| Feature | Free | Plus ($19.99/mo) | Premium ($49.99/mo) | Pro ($99.99/mo) |
| :---- | :---- | :---- | :---- | :---- |
| **Personas** | 2 | 5 | 15 | Unlimited |
| **Instances** | 3 | 10 | Unlimited | Unlimited |
| **Chats per day** (without BYOK) | 5 | 50 | Unlimited | Unlimited |
| **Models** | Basic only | Standard | All | All \+ Priority |
| **Storage** | 1 GB | 10 GB | 50 GB | 200 GB |
| **File uploads** | ✗ | ✓ | ✓ | ✓ |
| **BYOK support** | ✓ (unlimited chat) | ✓ (unlimited chat) | ✓ (unlimited chat) | ✓ (unlimited chat) |
| **Instance Types & Templates** | ✗ | ✗ | ✓ | ✓ |
| **Multi-model routing** | ✗ | ✗ | ✓ | ✓ |
| **External storage integrations** | ✗ | External only | Hybrid | Full hybrid \+ backup |
| **Global File System** | ✗ | ✗ | ✓ | ✓ |
| **Agentic Teams** | ✗ | ✗ | ✗ | ✓ |
| **Browser workspace** | ✗ | ✗ | ✗ | ✓ |
| **Encrypted export** | ✗ | ✗ | ✗ | ✓ |
| **API access** | ✗ | ✗ | ✗ | ✓ |
| **Export formats** | Markdown only | MD, JSON | MD, JSON, PDF, HTML | All \+ encrypted ZIP |
| **Bulk actions** | ✗ | ✗ | ✓ | ✓ |
| **Settings cascade** | Global only | Global \+ Instance | Full 4-layer | Full 4-layer |
| **Credits system** | ✓ (pay-as-you-go) | ✗ (included) | ✗ (included) | ✗ (included) |

### BYOK (Bring Your Own Key) 

BYOK is available on every tier and is the primary mechanism for free users to access unlimited AI chat without subscribing.

**How it works:**

1. User navigates to Settings → API Keys  
2. User enters their OpenRouter API key  
3. System calls OpenRouter's `/auth/key` endpoint to validate  
4. On success: key is encrypted and stored in the `api_keys` table  
5. All subsequent AI model calls route through the user's key instead of the platform's key  
6. Chat limits for the user's tier are lifted (no daily cap)  
7. The user pays OpenRouter directly for model usage

**UI indicator:** When BYOK is active, the Settings → General → Billing section shows a green "BYOK: Active" badge. The model selector in the chat composer also shows a subtle "via your key" indicator.

**Key storage:**

```sql
CREATE TABLE public.api_keys (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  provider TEXT NOT NULL DEFAULT 'openrouter' CHECK (provider IN ('openrouter')),
  encrypted_key TEXT NOT NULL,
  key_hint TEXT NOT NULL,              -- Last 4 characters for display: "...xK9m"
  status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'invalid', 'revoked')),
  last_validated_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.api_keys ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own API keys"
  ON public.api_keys FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_api_keys_user ON public.api_keys(user_id);
```

**Encryption:** API keys are encrypted at rest using Supabase Vault or a platform-managed AES-256 encryption key stored as an environment variable. The `encrypted_key` column stores the ciphertext. Decryption only happens server-side in Edge Functions when making model calls. The plaintext key is never sent to the client after initial submission.

### Credits System (Free Tier) 

Credits provide free-tier users with a pay-as-you-go mechanism to unlock features without committing to a subscription. New accounts receive 100 credits as a welcome bonus.

**Credit costs:**

| Action | Credits |
| :---- | :---- |
| Add 1 extra Instance (permanent) | 10 |
| Expand storage by 1 GB (permanent) | 30 |
| Unlock temporary model assignment (24 hours) | 20 |
| Export more than 1 chat (per export) | 5 |
| Enable voice interaction (24 hours) | 10 |
| Grant temporary access to advanced settings (24 hours) | 50 |

**Credit purchase:** Users buy credits via Stripe. Credit packs: 100 credits for $4.99, 300 credits for $12.99, 1000 credits for $34.99.

```sql
CREATE TABLE public.credit_transactions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  amount INTEGER NOT NULL,                -- Positive = purchase/grant, negative = spend
  balance_after INTEGER NOT NULL,
  description TEXT NOT NULL,              -- "Purchased 100 credits", "Added extra Instance", etc.
  stripe_payment_id TEXT,                 -- Null for grants and spends
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.credit_transactions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own transactions"
  ON public.credit_transactions FOR SELECT
  USING (auth.uid() = user_id);

CREATE INDEX idx_credit_transactions_user ON public.credit_transactions(user_id);
CREATE INDEX idx_credit_transactions_created ON public.credit_transactions(created_at DESC);
```

### Subscriptions 

```sql
CREATE TABLE public.subscriptions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL UNIQUE REFERENCES public.profiles(id) ON DELETE CASCADE,
  stripe_customer_id TEXT NOT NULL,
  stripe_subscription_id TEXT,
  tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'plus', 'premium', 'pro')),
  status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'canceled', 'past_due', 'trialing')),
  current_period_start TIMESTAMPTZ,
  current_period_end TIMESTAMPTZ,
  cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own subscription"
  ON public.subscriptions FOR SELECT
  USING (auth.uid() = user_id);

-- Only service role can update subscriptions (via Stripe webhook)
CREATE POLICY "Service role manages subscriptions"
  ON public.subscriptions FOR ALL
  USING (auth.role() = 'service_role');

CREATE INDEX idx_subscriptions_user ON public.subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe ON public.subscriptions(stripe_customer_id);
```

### Stripe Integration 

**Webhook endpoint:** Supabase Edge Function at `POST /functions/v1/stripe-webhook`

**Events to handle:**

| Stripe Event | Action |
| :---- | :---- |
| `checkout.session.completed` | Create subscription record, update `profiles.tier` |
| `customer.subscription.updated` | Sync tier and status changes |
| `customer.subscription.deleted` | Set tier to 'free', status to 'canceled' |
| `invoice.payment_failed` | Set status to 'past\_due', notify user |
| `invoice.paid` | Clear 'past\_due' status if applicable |

**Checkout flow:**

1. User clicks "Select" on a plan in the Pricing Modal  
2. Client calls `/api/create-checkout-session` with the selected `tier`  
3. Server creates a Stripe Checkout Session with the corresponding price ID  
4. User is redirected to Stripe Checkout  
5. After payment → Stripe sends webhook → Edge Function updates subscription and profile tier  
6. User is redirected back to aiConnected with a success parameter  
7. UI shows confirmation toast and refreshes tier data

### Feature Gate Utility 

A single utility function determines what features are available for a given tier. This is used both client-side (for UI gating — showing upgrade prompts) and server-side (for enforcing limits in Edge Functions).

```ts
// src/lib/utils/tier-gates.ts

type Tier = "free" | "plus" | "premium" | "pro";

interface TierLimits {
  maxPersonas: number;
  maxInstances: number;
  maxChatsPerDay: number;        // -1 = unlimited
  maxStorageBytes: number;
  allowFileUploads: boolean;
  allowInstanceTypes: boolean;
  allowMultiModelRouting: boolean;
  allowExternalStorage: boolean;
  allowHybridStorage: boolean;
  allowGlobalFileSystem: boolean;
  allowTeams: boolean;
  allowBrowser: boolean;
  allowEncryptedExport: boolean;
  allowApiAccess: boolean;
  allowBulkActions: boolean;
  allowFullCascade: boolean;
  exportFormats: string[];
}

const TIER_LIMITS: Record<Tier, TierLimits> = {
  free: {
    maxPersonas: 2,
    maxInstances: 3,
    maxChatsPerDay: 5,
    maxStorageBytes: 1_073_741_824,          // 1 GB
    allowFileUploads: false,
    allowInstanceTypes: false,
    allowMultiModelRouting: false,
    allowExternalStorage: false,
    allowHybridStorage: false,
    allowGlobalFileSystem: false,
    allowTeams: false,
    allowBrowser: false,
    allowEncryptedExport: false,
    allowApiAccess: false,
    allowBulkActions: false,
    allowFullCascade: false,
    exportFormats: ["markdown"],
  },
  plus: {
    maxPersonas: 5,
    maxInstances: 10,
    maxChatsPerDay: 50,
    maxStorageBytes: 10_737_418_240,         // 10 GB
    allowFileUploads: true,
    allowInstanceTypes: false,
    allowMultiModelRouting: false,
    allowExternalStorage: true,
    allowHybridStorage: false,
    allowGlobalFileSystem: false,
    allowTeams: false,
    allowBrowser: false,
    allowEncryptedExport: false,
    allowApiAccess: false,
    allowBulkActions: false,
    allowFullCascade: false,
    exportFormats: ["markdown", "json"],
  },
  premium: {
    maxPersonas: 15,
    maxInstances: -1,                        // unlimited
    maxChatsPerDay: -1,
    maxStorageBytes: 53_687_091_200,         // 50 GB
    allowFileUploads: true,
    allowInstanceTypes: true,
    allowMultiModelRouting: true,
    allowExternalStorage: true,
    allowHybridStorage: true,
    allowGlobalFileSystem: true,
    allowTeams: false,
    allowBrowser: false,
    allowEncryptedExport: false,
    allowApiAccess: false,
    allowBulkActions: true,
    allowFullCascade: true,
    exportFormats: ["markdown", "json", "pdf", "html"],
  },
  pro: {
    maxPersonas: -1,                         // unlimited
    maxInstances: -1,
    maxChatsPerDay: -1,
    maxStorageBytes: 214_748_364_800,        // 200 GB
    allowFileUploads: true,
    allowInstanceTypes: true,
    allowMultiModelRouting: true,
    allowExternalStorage: true,
    allowHybridStorage: true,
    allowGlobalFileSystem: true,
    allowTeams: true,
    allowBrowser: true,
    allowEncryptedExport: true,
    allowApiAccess: true,
    allowBulkActions: true,
    allowFullCascade: true,
    exportFormats: ["markdown", "json", "pdf", "html", "encrypted_zip"],
  },
};

export function getTierLimits(tier: Tier): TierLimits {
  return TIER_LIMITS[tier];
}

export function canAccess(tier: Tier, feature: keyof TierLimits): boolean {
  const limits = TIER_LIMITS[tier];
  const value = limits[feature];
  if (typeof value === "boolean") return value;
  if (typeof value === "number") return value !== 0;
  return true;
}

export function isWithinLimit(tier: Tier, feature: "maxPersonas" | "maxInstances" | "maxChatsPerDay", currentCount: number): boolean {
  const limit = TIER_LIMITS[tier][feature];
  if (limit === -1) return true; // unlimited
  return currentCount < limit;
}

export function getStorageLimitDisplay(tier: Tier): string {
  const bytes = TIER_LIMITS[tier].maxStorageBytes;
  if (bytes >= 1_073_741_824) return `${Math.round(bytes / 1_073_741_824)} GB`;
  return `${Math.round(bytes / 1_048_576)} MB`;
}
```

### Pricing Modal UI 

The Pricing Modal is triggered from Settings → Billing → "Compare Plans" button. It can also be triggered from any tier-gated feature that shows an upgrade prompt.

**Layout:** Full-screen overlay with centered card (680px max-width, 95% on mobile).

**Content:**

- Header: "Compare Plans" \+ close (X) button  
- 4-column grid (2 columns on mobile) — one card per tier  
- Each tier card contains:  
  - Tier name (15px, weight 500\)  
  - Price (22px, weight 300, letter-spacing \-0.02em) \+ "/mo" suffix (11px, muted)  
  - Feature list: each feature as a line with a small dot indicator (3px circle) \+ feature text (11px, weight 350\)  
  - Bottom action: "Current Plan" text if active, or "Select" button if not active  
- Current plan card: highlighted with `surfaceAlt` background and `accent` border

**Interaction:** Clicking "Select" on a non-current plan initiates the Stripe Checkout flow. Clicking outside the modal or pressing Escape closes it.

---

## 2.4 Onboarding Flow 

### Trigger Conditions 

The onboarding modal appears automatically on the user's first sign-in (`profiles.onboarding_completed = false`). After the user completes or skips it, `onboarding_completed` is set to `true`. Users can replay the tour from Settings → General → "Replay Welcome Tour."

### Onboarding Modal Design 

The modal is a fixed-position overlay covering the entire viewport with a semi-transparent backdrop (`rgba(0,0,0,0.5)`). The content card is centered, 420px wide (90% on mobile), with 40px padding (28px on mobile), border-radius 20px, and an elevated shadow.

### Step Sequence 

**Step 0: Welcome Screen**

- Sparkles icon (36px, `text` color)  
- Heading: "Welcome to aiConnected" (22px, weight 300, letter-spacing \-0.02em)  
- Subheading: "A digital intelligence environment where AI Personas work alongside you with real memory and skill." (14px, weight 300, `textMuted`, line-height 1.6)  
- "Take a Quick Tour" primary button (accent bg, 14px, weight 450, border-radius 8, padding 10px 32px)  
- "Skip" text link below (12px, `textFaint`)  
- No progress indicators on this step

**Step 1: Chat**

- Icon: MessageSquare (24px, `textFaint`)  
- Title: "Chat" (20px, weight 400\)  
- Description: "Your conversations live here. This is the backbone of everything." (14px, weight 300, `textMuted`, line-height 1.5)  
- Progress dots \+ navigation buttons

**Step 2: Spaces**

- Icon: LayoutGrid (24px, `textFaint`)  
- Title: "Spaces"  
- Description: "Organize work into Instances — each a complete workspace."

**Step 3: People**

- Icon: Users (24px, `textFaint`)  
- Title: "People"  
- Description: "AI Personas with real memory, skills, and boundaries."

**Step 4: Search**

- Icon: Search (24px, `textFaint`)  
- Title: "Search"  
- Description: "Find anything across chats, files, memories, and the web."

### Progress Indicators 

Steps 1–4 show a horizontal dot row centered above the navigation buttons:

- 4 dots total  
- Active step: 20px wide pill shape, `accent` color  
- Inactive steps: 6px circle, `surfaceAlt` color  
- Width transition: `0.2s ease`

### Navigation 

- Bottom bar with two buttons: "Skip" (left, 12px, `textFaint`) and "Next" or "Get Started" (right, accent bg, 13px, weight 450, border-radius 8, padding 8px 24px)  
- Step 4 shows "Get Started" instead of "Next"  
- Clicking "Get Started" or "Skip" at any step → close modal, mark `onboarding_completed = true`

### Post-Onboarding State 

After completing onboarding, the user lands on the Home screen. On first visit:

- No instances exist → Instance section shows the "Create your first Instance" empty state card  
- No personas exist → Persona section shows the "Meet your first Persona" empty state card  
- No chats exist → Chat list is empty with a prompt to start a conversation  
- Interface is in Standard mode (advanced features hidden)  
- Theme follows system preference  
- No BYOK key connected  
- Credits balance: 100 (welcome bonus)

### Progressive Feature Discovery 

As the user grows, the system nudges them toward more advanced features:

| Trigger | Nudge |
| :---- | :---- |
| 3+ chats created without any Instance | "Would you like to organize these into an Instance?" |
| 5+ messages exchanged without persona | "Have you tried creating a Persona? They remember your preferences." |
| Instance created with 10+ chats | "You could use Instance Types to organize similar workspaces." (Premium+) |
| Storage \> 50% used | "Running low on storage. Upgrade or connect external storage." |
| BYOK not connected \+ approaching daily limit | "Connect your OpenRouter key for unlimited conversations." |

These nudges appear as non-blocking banner messages in the relevant screen, dismissible, and only shown once per trigger per user. Tracked in a `nudges_dismissed` JSONB column on the `profiles` table (or a separate `nudge_history` table if complexity warrants it).

---

## 2.5 Auth-Related Environment Variables 

The following environment variables must be configured in both the Vercel project and the Supabase Edge Functions:

```
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://[project-ref].supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=[anon-key]
SUPABASE_SERVICE_ROLE_KEY=[service-role-key]

# Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_PRICE_PLUS=price_...
STRIPE_PRICE_PREMIUM=price_...
STRIPE_PRICE_PRO=price_...
STRIPE_PRICE_CREDITS_100=price_...
STRIPE_PRICE_CREDITS_300=price_...
STRIPE_PRICE_CREDITS_1000=price_...

# API Key Encryption
API_KEY_ENCRYPTION_SECRET=[32-byte-hex-string]

# OAuth (configured in Supabase Dashboard, not in env)
# Google Client ID/Secret → Supabase Dashboard → Auth → Providers → Google
# GitHub Client ID/Secret → Supabase Dashboard → Auth → Providers → GitHub
```

---

*End of Part 2\. Proceed to Part 3: Application Shell & Navigation.*

# PART 3: APPLICATION SHELL & NAVIGATION 

---

The application shell is the persistent frame that wraps every screen. It consists of the sidebar, top bar, and the content area (with an optional right panel on the Chat screen). The shell is the first thing Claude Code should build — it provides the navigation foundation for every subsequent screen.

---

## 3.1 Shell Layout 

The root layout is a full-viewport horizontal flex container:

```
┌──────────┬──────────────────────────────────────────────┐
│          │  TOP BAR (48px)                               │
│ SIDEBAR  ├──────────────────────────────────┬────────────┤
│ 56–200px │  CONTENT AREA (flex: 1)          │ RIGHT      │
│          │  (scrolls independently)         │ PANEL      │
│          │                                  │ 250px      │
│          │                                  │ (chat only)│
└──────────┴──────────────────────────────────┴────────────┘
```

```ts
// Root container styles
{
  width: "100%",
  height: "100vh",
  display: "flex",
  fontFamily: "'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif",
  overflow: "hidden",
  background: theme.bg,
}
```

The sidebar is the first flex child (fixed width, no shrink). The main area is the second flex child (flex: 1\) containing the top bar and content stacked vertically. On mobile (\< 768px), the sidebar is hidden and replaced by a hamburger overlay.

---

## 3.2 Sidebar Navigation 

### Dimensions & Behavior 

| State | Width | Content | Trigger |
| :---- | :---- | :---- | :---- |
| Expanded | 200px | Icons \+ text labels | Default on desktop, or toggled via ⌘. / sidebar button |
| Collapsed | 56px | Icons only, centered | Toggled via ⌘. / sidebar button |
| Hover-expanded | 200px (temporary) | Icons \+ text labels | Mouse enters collapsed sidebar; reverts on mouse leave |
| Hidden | 0px | None | Mobile breakpoint (\< 768px); replaced by overlay |
| Auto-collapsed | 56px | Icons only | Entering browser browse mode (automatic) |

**State variables:**

```ts
sExp: boolean       // true = expanded, false = collapsed
sHover: boolean     // true = mouse is hovering over collapsed sidebar
```

**Width calculation:**

```ts
const sideW = mobile ? 0 : (sHover || sExp) ? 200 : 56;
const showLabel = sHover || sExp;
```

**Transition:** `transition: width 0.2s ease` on the sidebar container.

### Visual Design 

The sidebar uses the dark sidebar tokens regardless of the active theme (light or dark). This creates a persistent dark navigation anchor on the left side.

```ts
// Sidebar container
{
  width: sideW,
  flexShrink: 0,
  background: sidebar.bg,                 // #111111 (light) or #0a0a0a (dark)
  display: "flex",
  flexDirection: "column",
  overflow: "hidden",
  transition: "width 0.2s ease",
  borderRight: `1px solid ${sidebar.border}`,
}
```

### Sidebar Sections (Top to Bottom) 

#### Header (60px) 

- Sparkles icon (18px, white) \+ "aiConnected" text (14px, weight 600, white, letter-spacing \-0.02em)  
- Centered when collapsed (icon only, no text)  
- Padding: 20px 16px (expanded), 20px 0 (collapsed)

#### Primary Navigation 

6 items, always visible:

| ID | Icon | Label | Notes |
| :---- | :---- | :---- | :---- |
| `home` | Home | Dashboard |  |
| `chat` | MessageSquare | Chat |  |
| `search` | Search | Search |  |
| `spaces` | LayoutGrid | Spaces |  |
| `files` | Folder | Files |  |
| `people` | Users | People |  |

Each nav item:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 12,
  cursor: "pointer",
  padding: showLabel ? "10px 16px" : "10px 0",
  justifyContent: showLabel ? "flex-start" : "center",
  background: isActive ? sidebar.bgActive : "transparent",
  color: isActive ? sidebar.text : sidebar.textMuted,
  margin: "1px 6px",
  borderRadius: 8,
  transition: "all 0.15s",
  whiteSpace: "nowrap",
}
```

- Icon: 18px, `flexShrink: 0`  
- Label: 13px, weight 500 (active) or 350 (inactive). Hidden when `showLabel` is false.

#### Divider 

A `1px solid sidebar.border` horizontal line with `margin: 12px 12px`.

#### Advanced Section 

"Advanced" section header (visible only when expanded): 10px, weight 500, `sidebar.textFaint`, uppercase, letter-spacing 0.08em, padding 4px 20px 8px.

3 items:

| ID | Icon | Label | Tier Badge |
| :---- | :---- | :---- | :---- |
| `teams` | Bot | Teams | "Pro" |
| `browser` | Globe | Browser | "Pro" |
| `insights` | BarChart3 | Insights | (none) |

Same item styling as primary nav, with an additional tier badge on the right side:

```ts
// Tier badge (when expanded and tier !== null)
{
  fontSize: 8,
  padding: "1px 5px",
  borderRadius: 4,
  background: "#2e95f3",
  color: "#fff",
  fontWeight: 600,
  letterSpacing: "0.04em",
  marginLeft: "auto",
}
```

#### Settings (Bottom Section) 

Separated by a `1px solid sidebar.border` top border with `padding: 8px 0`.

Single item: Settings icon (18px) \+ "Settings" label (13px, weight 350). Same active/inactive styling as other nav items.

#### User Profile Chip (Very Bottom) 

Separated by a `1px solid sidebar.border` top border.

```ts
{
  padding: showLabel ? "14px 16px" : "14px 0",
  display: "flex",
  alignItems: "center",
  gap: 10,
  justifyContent: showLabel ? "flex-start" : "center",
}
```

- Avatar: 28px circle with user initial, dark background (\#333), muted text (\#888)  
- Name: 12px, weight 500, `sidebar.text`  
- Tier label: 10px, weight 300, `sidebar.textFaint` (e.g., "Pro Plan")  
- When collapsed: avatar only, centered

### Mobile Sidebar Overlay 

On mobile, the sidebar is replaced by a full-screen overlay triggered by the hamburger menu (Menu icon, 18px) in the top bar.

**Layout:**

- Fixed overlay covering entire viewport, z-index 50  
- Left panel: 260px wide, dark sidebar background (`T.light.sidebar.bg`)  
- Right backdrop: flex 1, `rgba(0,0,0,0.5)`, click to dismiss

**Content:**

- Header row: aiConnected logo \+ close (X) button  
- Same nav items as desktop, but always showing labels  
- Simplified: no advanced section header, no tier badges, no user profile chip  
- Clicking any item navigates and closes the overlay (`setMobileNav(false)`)

### Auto-Collapse on Browser Mode 

When the user enters browser browse mode, the sidebar automatically collapses to give maximum viewport width to the browser. This is triggered in three places:

1. Clicking "Open Browser" from sessions screen  
2. Clicking "Revisit" on a history item  
3. Clicking "Resume" on an active session

Implementation: `setSExp(false)` is called at each entry point.

---

## 3.3 Top Bar 

### Dimensions 

Height: 48px. Full width of the main area (to the right of the sidebar). `flexShrink: 0`. Background: `theme.surface`. Bottom border: `1px solid theme.borderSubtle`.

### Layout 

Horizontal flex with `justifyContent: "space-between"` and `padding: 0 16px`.

**Left side:** Breadcrumbs \+ sidebar toggle **Right side:** Action buttons

### Breadcrumbs 

Breadcrumbs are generated dynamically based on the current screen and sub-context. Each crumb is a text span; all but the last crumb are clickable and navigate to that level.

**Breadcrumb generation logic:**

| Screen | Crumbs |
| :---- | :---- |
| `home` | Dashboard |
| `chat` | Chat \> `{Instance Name}` \> `{Chat Title}` |
| `search` | Search |
| `files` | Files |
| `teams` | Teams |
| `browser` (sessions) | Browser |
| `browser` (browse) | Browser \> `{domain}` |
| `insights` | Insights |
| `spaces` (list) | Spaces |
| `spaces` (detail) | Spaces \> `{Instance Name}` \> `{Tab Name}` (if not Overview) |
| `people` (list) | People |
| `people` (detail) | People \> `{Persona Name}` |
| `settings` | Settings \> `{Tab Label}` (if not General) |

**Crumb styling:**

```ts
// Non-terminal crumb
{ fontSize: 12, color: theme.textMuted, fontWeight: 300, cursor: "pointer" }

// Terminal crumb (last item)
{ fontSize: 12, color: theme.text, fontWeight: 500, cursor: "default" }

// Separator between crumbs
<ChevronRight size={11} color={theme.textFaint} />
```

### Sidebar Toggle Button 

Positioned immediately after the first breadcrumb, desktop only. Not shown on mobile.

```ts
{
  background: "none",
  border: "none",
  cursor: "pointer",
  color: theme.textFaint,
  display: "flex",
  padding: 2,
  marginLeft: 2,
  borderRadius: 4,
}
```

- When sidebar expanded: PanelRightClose icon (14px) → clicking collapses  
- When sidebar collapsed: PanelRightOpen icon (14px) → clicking expands

### Right-Side Action Buttons 

All buttons share this base style:

```ts
{
  background: "none",
  border: "none",
  cursor: "pointer",
  color: theme.textMuted,   // or theme.text when active
  display: "flex",
  padding: 5,
}
```

Buttons appear in this order, left to right:

#### \+New Button (Always Visible) 

Opens the Command Palette.

```ts
{
  background: "none",
  border: `1px solid ${theme.border}`,
  borderRadius: 6,
  padding: "3px 8px",
  cursor: "pointer",
  color: theme.textMuted,
  display: "flex",
  alignItems: "center",
  gap: 3,
  fontSize: 11,
  fontWeight: 400,
}
```

Content: Plus icon (12px) \+ "New" text. Title tooltip: "New... (⌘N)".

#### Chat Drawer Toggle (Chat Screen Only, Desktop Only) 

MessageSquare icon (14px). Color: `theme.text` when drawer is open, `theme.textFaint` when closed. Clicking toggles `chatDrawer` state.

#### Notification Bell (Always Visible) 

Bell icon (15px). Positioned in a `relative` container for the dropdown.

**Unread indicator:** When any notifications are unread, show a 6px red circle (`#ef4444`) positioned `top: 3, right: 3` of the button container.

Clicking toggles the Notification Dropdown (see Section 3.5).

#### Theme Toggle (Always Visible) 

Moon icon (15px) in light mode, Sun icon (15px) in dark mode. Clicking toggles theme.

#### Right Panel Toggle (Chat Screen Only, Desktop Only) 

PanelRightClose icon (15px) when panel is open, PanelRightOpen (15px) when closed. Color: `theme.text` when open, `theme.textFaint` when closed. Clicking toggles `rOpen` state.

### Mobile Top Bar Differences 

- Hamburger button (Menu icon, 18px) replaces the sidebar toggle, positioned first on the left  
- Chat drawer button (Inbox icon, 15px) appears next to hamburger when on chat screen  
- Right panel toggle is hidden (panel not available on mobile)  
- Chat drawer toggle is hidden (handled by Inbox button)

---

## 3.4 Command Palette 

### Trigger 

- Clicking the "+New" button in the top bar  
- Pressing ⌘N (or Ctrl+N on Windows/Linux)

### Layout 

Fixed overlay covering viewport with `rgba(0,0,0,0.4)` backdrop, z-index 60\. Clicking backdrop closes palette.

Content card: centered horizontally, positioned at 15% from viewport top (`paddingTop: "15vh"`), 440px wide (90% on mobile), `theme.surface` background, border-radius 16px, elevated shadow.

### Structure 

**Search input row:** Top section with bottom border.

- Search icon (16px, `textMuted`) \+ input field (15px, weight 350, auto-focus, placeholder "What do you want to create?") \+ ESC badge (10px, `textFaint`, `surfaceAlt` background, border-radius 4px)

**Action list:** Below the search input, padding 8px 6px.

| Icon | Label | Description | Shortcut | Action |
| :---- | :---- | :---- | :---- | :---- |
| MessageSquare (16px) | New Chat | Start a conversation | ⌘N | Navigate to chat, create new conversation |
| LayoutGrid (16px) | New Instance | Create a workspace | ⌘⇧I | Open create instance flow |
| Users (16px) | New Persona | Add a team member | ⌘⇧P | Open create persona modal |
| Search (16px) | Search | Find anything | ⌘K | Navigate to search screen |
| Upload (16px) | Upload File | Add a file | ⌘U | Open file upload dialog |

Each action item:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 12,
  padding: "10px 12px",
  borderRadius: 8,
  cursor: "pointer",
  margin: "1px 0",
}
```

- Icon: `textFaint` color  
- Label: 14px, weight 400, `text` color  
- Description: 11px, weight 300, `textMuted`  
- Shortcut: 10px, `textFaint`, monospace font, right-aligned

### Filtering Behavior 

As the user types in the search input, the action list filters to show only matching items (match against label and description). If no items match, show "No results" placeholder.

### Dismiss 

- Click backdrop  
- Press Escape  
- Click any action item (also performs the action)

---

## 3.5 Notification System 

### Database Schema 

```sql
CREATE TABLE public.notifications (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  type TEXT NOT NULL CHECK (type IN ('persona', 'system', 'chat')),
  text TEXT NOT NULL,
  metadata JSONB DEFAULT '{}'::jsonb,
  read BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own notifications"
  ON public.notifications FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users update own notifications"
  ON public.notifications FOR UPDATE
  USING (auth.uid() = user_id);

CREATE INDEX idx_notifications_user_unread ON public.notifications(user_id, read) WHERE read = false;
CREATE INDEX idx_notifications_user_created ON public.notifications(user_id, created_at DESC);
```

### Notification Types 

| Type | Icon | Examples |
| :---- | :---- | :---- |
| `persona` | Users (12px) | "Sally updated hero section mockup", "Dev completed React Architecture component tree", "Sally flagged a memory conflict" |
| `system` | Bell (12px) | "Auto-rename suggested for 2 chats", "Weekly summary: 23 conversations, 42 new memories", "File storage usage at 85%" |
| `chat` | MessageSquare (12px) | "You were mentioned in Client Website Redesign", "Response ready in Hero Section Layout" |

### Notification Dropdown 

Triggered by clicking the Bell icon in the top bar. Positioned absolutely, anchored to the top-right of the bell button.

```ts
{
  position: "absolute",
  right: 0,
  top: "100%",
  marginTop: 4,
  width: 300,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 12,
  boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
  zIndex: 30,
  overflow: "hidden",
}
```

**Header row:** "Notifications" (13px, weight 500\) \+ "Mark all read" action (10px, `textMuted`, cursor pointer). Padding 12px 14px, bottom border.

**Notification list:** Max-height 280px, scrollable. Shows the 5 most recent notifications.

Each notification item:

```ts
{
  padding: "10px 14px",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  gap: 10,
  alignItems: "flex-start",
  background: notification.read ? "transparent" : theme.surfaceAlt,
}
```

- Left: type icon (12px, `textFaint`, marginTop 3px)  
- Center: text (12px, weight 400 if unread / 350 if read, line-height 1.4) \+ time below (10px, `textFaint`, weight 300\)  
- Right: unread dot (6px circle, `theme.accent`) if unread

**Footer:** "View all notifications" link (11px, `textMuted`, weight 400, centered). Clicking opens the Notification History modal and closes the dropdown.

**Dismiss:** Clicking outside the dropdown closes it.

### Notification History Modal 

Full-screen overlay with centered card (520px, 92% on mobile), max-height 75vh, scrollable content.

**Header:** "All Notifications" (16px, weight 450\) \+ close (X) button. Padding 18px 22px, bottom border.

**List:** All notifications (not just 5), each with slightly larger padding (14px 22px) and larger text (13px for text, 11px for time).

**Real-time delivery:** Notifications are delivered in real-time via a Supabase Realtime subscription on the `notifications` table filtered by `user_id`. When a new notification arrives:

1. The bell's unread dot appears/updates  
2. If the dropdown is open, the new notification appears at the top  
3. Optionally: a brief toast appears at the bottom of the screen (future enhancement)

### "Mark All Read" Action 

Clicking "Mark all read" in the dropdown header calls:

```sql
UPDATE notifications SET read = true WHERE user_id = auth.uid() AND read = false;
```

The UI optimistically updates all notification items to read state and removes the bell's unread dot.

---

## 3.6 Keyboard Shortcuts 

### Global Shortcut Map 

| Shortcut | Description | Action |
| :---- | :---- | :---- |
| ⌘N | New (Command palette) | Open command palette |
| ⌘K | Search | Navigate to search screen, focus search input |
| ⌘/ | Shortcuts | Open keyboard shortcuts modal |
| ⌘⇧I | New Instance | Open create instance flow |
| ⌘⇧P | New Persona | Open create persona modal |
| ⌘U | Upload file | Open file upload dialog |
| ⌘D | Toggle dark mode | Switch between light and dark theme |
| ⌘. | Toggle sidebar | Expand/collapse sidebar |
| Esc | Close modal | Close the topmost open modal or overlay |
| ⌘E | Export chat | Open export modal (chat screen only) |

### Implementation 

A global `useEffect` hook on the root layout component listens for `keydown` events and dispatches the appropriate action. Shortcuts use `metaKey` (Mac) or `ctrlKey` (Windows/Linux).

```ts
// src/hooks/use-keyboard-shortcuts.ts
import { useEffect } from "react";

interface ShortcutMap {
  [key: string]: () => void;
}

export function useKeyboardShortcuts(shortcuts: ShortcutMap) {
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      const meta = e.metaKey || e.ctrlKey;
      if (!meta) return;

      let key = e.key.toLowerCase();
      if (e.shiftKey) key = `shift+${key}`;

      const action = shortcuts[key];
      if (action) {
        e.preventDefault();
        action();
      }
    };

    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [shortcuts]);
}
```

**Context awareness:** Some shortcuts only apply on certain screens (e.g., ⌘E only works on the chat screen). The shortcut map passed to the hook should be rebuilt when the screen changes.

**Escape key handling:** Escape does not require meta/ctrl. It is handled separately and closes modals in LIFO order: command palette \> export modal \> pricing modal \> notification history \> shortcuts modal \> onboarding.

### Shortcuts Modal 

Triggered by pressing ⌘/ or clicking the "?" help button (if present).

**Layout:** Fixed overlay with backdrop (`rgba(0,0,0,0.4)`), centered card (400px, 90% on mobile), `theme.surface` background, border-radius 16px, padding 28px.

**Header:** "Keyboard Shortcuts" (16px, weight 450\) \+ close (X) button.

**Grid:** 2-column grid (gap 4px) showing all shortcuts:

Each shortcut row:

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  padding: "8px 10px",
  borderRadius: 6,
}
```

- Description: 12px, `textSec`, weight 350  
- Key badge: 10px, monospace, `textFaint`, background `surfaceAlt`, border-radius 4px, padding 2px 6px

---

## 3.7 Export Modal 

The Export Modal is accessible from the Filters tab of the right panel (chat screen) and via ⌘E.

**Layout:** Fixed overlay, centered card (380px, 85% on mobile), padding 28px, border-radius 16px.

**Header:** "Export Conversation" (16px, weight 450\) \+ close button.

**Description:** "Choose a format to export this conversation." (12px, `textMuted`, weight 300).

**Format grid:** 2x2 grid (gap 10px) of format cards:

| Format | Extension | Icon |
| :---- | :---- | :---- |
| Markdown | .md | FileText (18px) |
| PDF | .pdf | FileText (18px) |
| JSON | .json | FileCode (18px) |
| HTML | .html | Globe (18px) |

Each format card:

```ts
{
  display: "flex",
  flexDirection: "column",
  alignItems: "center",
  gap: 6,
  padding: 16,
  background: theme.surfaceAlt,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  cursor: "pointer",
  color: theme.text,
  fontSize: 13,
  fontWeight: 400,
}
```

**Scope selector:** Below the format grid, two toggle buttons in a row:

- "Full conversation" (active by default, `surfaceAlt` background with border)  
- "Filtered only" (transparent with border)

Clicking a format card initiates the export with the selected scope, closes the modal, and triggers a download.

**Tier gating:** PDF and HTML formats are available on Premium+. Free tier only sees Markdown. Plus tier sees Markdown \+ JSON. The unavailable format cards show a lock icon and tier badge, and clicking them opens the Pricing Modal instead of exporting.

---

*End of Part 3\. Proceed to Part 4: Home / Dashboard Screen.*

# PART 4: HOME / DASHBOARD SCREEN 

---

The Home screen is the user's landing page after sign-in. It provides a high-level overview of their entire workspace — quick stats, recent activity, and easy entry points to the most important areas of the platform. It is intentionally simple: a clean vertical flow with generous whitespace, no sidebars, no tabs. The screen reads like a briefing — what happened, what's active, where to go next.

---

## 4.1 Layout 

### Container 

```ts
{
  padding: `${py}px ${px}px`,    // 40px 48px (desktop), 24px 20px (mobile)
  overflowY: "auto",
  height: "100%",
}

// Inner content wrapper
{
  maxWidth: 900,
  margin: "0 auto",
}
```

The screen scrolls vertically if content exceeds the viewport. There is no right panel on this screen. The main sidebar remains visible (it is global to all screens).

### Content Flow 

The dashboard content is a single vertical column with 4 sections:

1. Greeting section (marginBottom 48px)  
2. Quick Stats grid (marginBottom 56px)  
3. Recent Activity feed (no bottom margin — it is the terminal section)  
4. (Empty State replaces sections 2 and 3 for first-time users)

---

## 4.2 Greeting Section 

### Container 

```ts
{
  marginBottom: 48,
}
```

### Greeting Text 

```ts
{
  fontSize: 28,
  fontWeight: 300,
  color: theme.text,
  letterSpacing: "-0.03em",
}
```

The greeting includes a time-of-day prefix and the user's display name:

```ts
function getGreeting(displayName: string): JSX.Element {
  const hour = new Date().getHours();
  let prefix: string;
  if (hour < 12) prefix = "Good morning";
  else if (hour < 17) prefix = "Good afternoon";
  else prefix = "Good evening";

  return (
    <span>
      {prefix},{" "}
      <span style={{ fontWeight: 600 }}>{displayName}</span>
    </span>
  );
}
```

The `displayName` is loaded from `profiles.display_name`. If the profile has no display name, fall back to the user's email prefix (the part before `@`). If that's also unavailable, fall back to "there" (e.g., "Good morning, there").

On the user's very first visit (before onboarding is completed), the greeting instead says:

```ts
// First visit only (profiles.onboarding_completed = false)
"Welcome to aiConnected"
// No name appended — the user hasn't set one yet
```

### Subtitle 

```ts
{
  fontSize: 14,
  color: theme.textMuted,
  marginTop: 8,
  fontWeight: 300,
}
// Text: "Here's your workspace at a glance."
```

For the first visit, the subtitle changes to:

```ts
// First visit
"Let's get your workspace set up."
```

---

## 4.3 Quick Stats Grid 

A 4-column grid (2 columns on mobile) showing aggregate counts from across the platform.

### Grid Container 

```ts
{
  display: "grid",
  gridTemplateColumns: mobile ? "1fr 1fr" : "1fr 1fr 1fr 1fr",
  gap: 24,
  marginBottom: 56,
}
```

### Stat Cards 

4 stat cards, each representing a core entity:

| Stat | Icon | Label | Click Navigation |
| :---- | :---- | :---- | :---- |
| Instances | LayoutGrid (16px) | "Instances" | Navigate to Spaces screen |
| Personas | Users (16px) | "Personas" | Navigate to People screen |
| Chats | MessageSquare (16px) | "Chats" | Navigate to Chat screen |
| Tasks | Hash (16px) | "Tasks" | Navigate to Spaces screen (scrolled to first instance with open tasks) |

Each stat card:

```ts
{
  cursor: "pointer",
  transition: "opacity 0.15s ease",
}
// On hover: opacity 0.7
// On mouseLeave: opacity 1
```

**Icon:**

```ts
{
  color: theme.textFaint,
  marginBottom: 12,
}
// <Icon size={16} />
```

**Number:**

```ts
{
  fontSize: 36,
  fontWeight: 200,
  color: theme.text,
  letterSpacing: "-0.04em",
  lineHeight: 1,
}
```

**Label:**

```ts
{
  fontSize: 12,
  color: theme.textMuted,
  marginTop: 6,
  fontWeight: 400,
  letterSpacing: "0.02em",
}
```

No borders, no backgrounds, no card containers. The stat cards rely purely on typography, spacing, and the icon to convey structure. This is deliberate — the dashboard should feel like a typographic briefing, not a widget grid.

### Data Source Queries 

Each stat counts against the user's own data:

| Stat | SQL |
| :---- | :---- |
| Instances | `SELECT COUNT(*) FROM instances WHERE user_id = auth.uid() AND archived_at IS NULL` |
| Personas | `SELECT COUNT(*) FROM personas WHERE user_id = auth.uid() AND archived_at IS NULL` |
| Chats | `SELECT COUNT(*) FROM chats WHERE user_id = auth.uid() AND deleted_at IS NULL` |
| Tasks | `SELECT COUNT(*) FROM tasks WHERE instance_id IN (SELECT id FROM instances WHERE user_id = auth.uid()) AND status != 'done'` |

### Data Loading Hook 

All 4 stats are fetched in a single React Query hook with parallel count queries:

```ts
// src/hooks/use-dashboard-stats.ts
export function useDashboardStats() {
  return useQuery({
    queryKey: ["dashboard-stats"],
    queryFn: async () => {
      const supabase = createClient();
      const [instances, personas, chats, tasks] = await Promise.all([
        supabase
          .from("instances")
          .select("id", { count: "exact", head: true })
          .is("archived_at", null),
        supabase
          .from("personas")
          .select("id", { count: "exact", head: true })
          .is("archived_at", null),
        supabase
          .from("chats")
          .select("id", { count: "exact", head: true })
          .is("deleted_at", null),
        supabase
          .from("tasks")
          .select("id", { count: "exact", head: true })
          .neq("status", "done"),
      ]);
      return {
        instances: instances.count ?? 0,
        personas: personas.count ?? 0,
        chats: chats.count ?? 0,
        tasks: tasks.count ?? 0,
      };
    },
    staleTime: 30_000,          // 30 seconds before considered stale
    refetchOnWindowFocus: true,  // Refresh when user returns to tab
  });
}
```

### Loading State 

While stats are loading, display "—" in place of each number. The layout is stable because the typography dimensions don't change between "—" and a number.

```ts
// Loading number
{
  fontSize: 36,
  fontWeight: 200,
  color: theme.textFaint,      // Faint instead of text
  letterSpacing: "-0.04em",
  lineHeight: 1,
}
// Display: "—"
```

No skeleton animation. The "—" is sufficient visual feedback for 4 small stat cells.

### Error State 

If the stats query fails, show "\!" in the number slot with a tooltip on hover: "Couldn't load stats. Click to retry."

```ts
// Error number
{
  fontSize: 36,
  fontWeight: 200,
  color: theme.textFaint,
  cursor: "pointer",
}
// Display: "!"
// onClick: refetch()
```

---

## 4.4 Activity Log Data Model 

The activity feed is powered by a shared `activity_log` table used across the entire platform. Any significant event — user action, persona action, or system event — inserts a row.

### Schema 

```sql
CREATE TABLE public.activity_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  actor_type TEXT NOT NULL CHECK (actor_type IN ('user', 'persona', 'system', 'cipher')),
  actor_id UUID,                            -- persona_id for persona actors, null for system/user
  actor_name TEXT NOT NULL,                 -- Display name for rendering: "Sally", "You", "System"
  action TEXT NOT NULL,                     -- Human-readable verb phrase: "updated hero section mockup"
  entity_type TEXT,                         -- "instance", "chat", "file", "persona", "memory", "task", "team", "browser_extract"
  entity_id UUID,                           -- ID of the affected entity for navigation
  entity_name TEXT,                         -- Display name: "Client Website Redesign"
  metadata JSONB DEFAULT '{}'::jsonb,       -- Additional context: { persona_avatar_config, instance_icon, etc. }
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.activity_log ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own activity"
  ON public.activity_log FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users insert own activity"
  ON public.activity_log FOR INSERT
  WITH CHECK (auth.uid() = user_id);

-- Service role can insert on behalf of any user (for triggers and Edge Functions)
CREATE POLICY "Service role insert"
  ON public.activity_log FOR INSERT
  USING (auth.role() = 'service_role');

CREATE INDEX idx_activity_log_user_created ON public.activity_log(user_id, created_at DESC);
```

### Activity Log Population 

Activity log entries are created by triggers and Edge Functions throughout the platform. Every part of the PRD that modifies data should insert an activity log entry for significant actions.

**Events and their log entries:**

| Event | actor\_type | actor\_name | action | entity\_type | entity\_name |
| :---- | :---- | :---- | :---- | :---- | :---- |
| User creates an Instance | user | "You" | "created a new workspace" | instance | Instance name |
| User creates a Persona | user | "You" | "created a new persona" | persona | Persona name |
| User creates a Team | user | "You" | "created team" | team | Team name |
| User starts a new Chat | user | "You" | "started a conversation" | chat | Chat title (or "New conversation") |
| User uploads a File | user | "You" | "uploaded a file" | file | File name |
| User creates a Browser Extract | user | "You" | "extracted content" | browser\_extract | Extract title |
| Persona responds in chat | persona | `{Persona name}` | `updated hero section mockup` | chat | Chat title |
| Persona completes a task | persona | `{Persona name}` | `completed competitor analysis` | task | Task title |
| Persona learns a new skill | persona | `{Persona name}` | `learned a new skill: {skill name}` | persona | Persona name |
| System extracts memories | system | `System` | `saved {n} new memories` | chat | Chat title |
| System auto-renames a chat | system | "System" | "renamed a conversation" | chat | New chat title |
| System suggests moving a chat | system | "System" | "suggested moving a chat" | chat | Chat title |
| Team run completes | system | "System" | "team run completed" | team | Team name |
| Team run fails | system | "System" | "team run failed" | team | Team name |
| Weekly cleanup runs | system | `System` | `archived {n} old chats` | (null) | (null) |

**Implementation approach:** For user-initiated actions (create, upload, delete), the activity log insert is performed in the same client-side function that performs the action (e.g., `createInstance()` also inserts an activity log entry). For persona and system actions, the Edge Function that processes the AI response or background job inserts the entry using the service role.

### metadata JSONB Examples 

The `metadata` field stores additional context that the dashboard can use for richer rendering without additional queries:

```ts
// Persona action
{
  persona_id: "uuid",
  instance_id: "uuid",
  instance_name: "Client Website Redesign",
}

// File upload
{
  file_type: "pdf",
  file_size: 2400000,
  instance_id: "uuid",
}

// Memory extraction
{
  memory_count: 3,
  memory_types: ["decision", "fact", "preference"],
}

// Team run
{
  run_status: "completed",
  tasks_completed: 8,
  tasks_total: 10,
  duration_ms: 145000,
}
```

---

## 4.5 Recent Activity Feed 

### Section Header 

```ts
{
  fontSize: 11,
  fontWeight: 500,
  color: theme.textMuted,
  textTransform: "uppercase",
  letterSpacing: "0.1em",
  marginBottom: 20,
}
// Text: "RECENT"
```

### Activity List Container 

```ts
{
  display: "flex",
  flexDirection: "column",
}
```

The activity list shows the 20 most recent events. Each item is a row with a bottom border separator.

### Activity Item 

```ts
{
  padding: "16px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  justifyContent: "space-between",
  alignItems: "baseline",
  cursor: "pointer",
}
// On hover: background subtly changes
// Actually: no background change needed. The cursor change is sufficient for a clean list.
```

**Left side** (flex 1, minWidth 0):

- Primary text: ``{actor_name} {action}`` (14px, weight 400, `text` color)

```ts
{
  fontSize: 14,
  color: theme.text,
  fontWeight: 400,
  overflow: "hidden",
  textOverflow: "ellipsis",
  whiteSpace: "nowrap",
}
```

The actor\_name should be visually distinguished. If actor\_type is "persona", render the name in weight 500\. If actor\_type is "user", show "You" in weight 400\. If "system", show "System" in `textMuted` color, weight 400\.

- Secondary text: ``{entity_name}`` (12px, weight 300, `textMuted`, marginTop 3px)

```ts
{
  fontSize: 12,
  color: theme.textMuted,
  fontWeight: 300,
  marginTop: 3,
  overflow: "hidden",
  textOverflow: "ellipsis",
  whiteSpace: "nowrap",
}
```

If `entity_name` is null (e.g., system cleanup events with no specific entity), the secondary text line is omitted entirely.

**Right side:**

- Timestamp: relative time string (11px, weight 300, `textFaint`, flexShrink 0, marginLeft 20px)

```ts
{
  fontSize: 11,
  color: theme.textFaint,
  fontWeight: 300,
  flexShrink: 0,
  marginLeft: 20,
  whiteSpace: "nowrap",
}
```

Relative time format: "2 min ago", "15 min ago", "1 hr ago", "3 hrs ago", "Yesterday", "Feb 11", etc. Uses a utility function:

```ts
function relativeTime(dateString: string): string {
  const now = Date.now();
  const then = new Date(dateString).getTime();
  const diffMs = now - then;
  const diffMin = Math.floor(diffMs / 60000);
  const diffHr = Math.floor(diffMs / 3600000);
  const diffDay = Math.floor(diffMs / 86400000);

  if (diffMin < 1) return "Just now";
  if (diffMin < 60) return `${diffMin} min ago`;
  if (diffHr < 24) return `${diffHr} hr${diffHr > 1 ? "s" : ""} ago`;
  if (diffDay < 2) return "Yesterday";
  if (diffDay < 7) return `${diffDay} days ago`;

  // Beyond a week: show date
  return new Date(dateString).toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
  });
}
```

### Click Navigation 

Clicking an activity item navigates to the relevant entity based on `entity_type` and `entity_id`:

```ts
function handleActivityClick(item: ActivityLogEntry) {
  switch (item.entity_type) {
    case "instance":
      navigate(`/spaces/${item.entity_id}`);
      break;
    case "chat":
      navigate(`/chat?id=${item.entity_id}`);
      break;
    case "file":
      navigate(`/files?highlight=${item.entity_id}`);
      break;
    case "persona":
      navigate(`/people/${item.entity_id}`);
      break;
    case "task":
      // Tasks belong to instances — navigate to the instance's Tasks tab
      const instanceId = item.metadata?.instance_id;
      if (instanceId) navigate(`/spaces/${instanceId}?tab=tasks`);
      break;
    case "team":
      navigate(`/teams/${item.entity_id}`);
      break;
    case "browser_extract":
      navigate(`/browser?tab=extracts&highlight=${item.entity_id}`);
      break;
    case "memory":
      // Navigate to the persona's Memory tab
      const personaId = item.metadata?.persona_id;
      if (personaId) navigate(`/people/${personaId}?tab=memory`);
      break;
    default:
      // No navigation for items without a clear destination
      break;
  }
}
```

Items without a valid `entity_type` or `entity_id` (e.g., system cleanup events) are not clickable — they show `cursor: default` instead of `cursor: pointer`.

### Data Query 

```ts
const { data: activities, isLoading: activitiesLoading } = useQuery({
  queryKey: ["recent-activity"],
  queryFn: async () => {
    const supabase = createClient();
    const { data, error } = await supabase
      .from("activity_log")
      .select("*")
      .order("created_at", { ascending: false })
      .limit(20);
    if (error) throw error;
    return data ?? [];
  },
  staleTime: 15_000,            // 15 seconds
  refetchOnWindowFocus: true,
});
```

### Loading State 

While the activity feed is loading, show 4 skeleton rows:

```ts
// Each skeleton row
{
  padding: "16px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  justifyContent: "space-between",
  alignItems: "baseline",
}
```

Left side:

- Primary line: 220px × 14px bar (`surfaceAlt` bg, borderRadius 4, animation: pulse 1.5s ease-in-out infinite)  
- Secondary line: 140px × 10px bar, marginTop 6px

Right side:

- Timestamp: 60px × 10px bar

### Empty Activity State 

When the activity list is empty (no events yet — different from the first-time empty state which replaces the whole screen):

```ts
{
  padding: "24px 0",
  textAlign: "center",
}
```

- "No recent activity" (13px, weight 300, `textFaint`)  
- "Activity from your conversations, personas, and instances will appear here." (12px, weight 300, `textFaint`, marginTop 4px, maxWidth 360px, margin auto)

### Real-Time Updates 

The activity feed subscribes to Supabase Realtime for live updates. When a new activity log entry is created, it appears at the top of the list without requiring a manual refresh.

```ts
useEffect(() => {
  const supabase = createClient();

  const channel = supabase
    .channel("dashboard-activity")
    .on(
      "postgres_changes",
      {
        event: "INSERT",
        schema: "public",
        table: "activity_log",
        filter: `user_id=eq.${userId}`,
      },
      (payload) => {
        // Prepend the new entry to the cached activity list
        queryClient.setQueryData<ActivityLogEntry[]>(
          ["recent-activity"],
          (old) => {
            if (!old) return [payload.new as ActivityLogEntry];
            return [payload.new as ActivityLogEntry, ...old].slice(0, 20);
          }
        );
      }
    )
    .subscribe();

  return () => {
    supabase.removeChannel(channel);
  };
}, [userId, queryClient]);
```

This means the dashboard is live — if a persona responds in a chat while the user is looking at the dashboard, the activity item appears immediately.

---

## 4.6 Empty State (First-Time User) 

When a new user has zero instances, zero personas, and zero chats, the Home screen replaces the stats grid and activity feed with a getting-started experience.

### Detection 

```ts
const isEmpty = stats.instances === 0 && stats.personas === 0 && stats.chats === 0;
```

This check runs after the stats query resolves. While loading, the normal layout (with "—" placeholders) is shown.

### Layout 

The greeting section remains. Below it, instead of stats \+ activity, show 3 action cards:

```ts
{
  display: "grid",
  gridTemplateColumns: mobile ? "1fr" : "1fr 1fr 1fr",
  gap: 16,
  marginTop: 32,
}
```

### Card Container 

Each card:

```ts
{
  padding: 24,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 12,
  display: "flex",
  flexDirection: "column",
  gap: 12,
}
```

### Card 1: "Create your first Instance" 

- Icon: LayoutGrid (24px, `textFaint`)  
- Title: "Create your first Instance" (15px, weight 450, `text`)  
- Description: "Instances are workspaces for projects, clients, or topics. They keep your conversations organized." (13px, weight 300, `textMuted`, lineHeight 1.5)  
- Button: "Create Instance"

```ts
{
  background: theme.accent,
  color: theme.accentText,
  border: "none",
  borderRadius: 6,
  padding: "8px 16px",
  fontSize: 12,
  fontWeight: 450,
  cursor: "pointer",
  alignSelf: "flex-start",   // Left-aligned, not full-width
}
```

Click action: navigate to Spaces screen with the create Instance modal pre-opened (`/spaces?create=true`).

### Card 2: "Meet your first Persona" 

- Icon: Users (24px, `textFaint`)  
- Title: "Meet your first Persona"  
- Description: "Personas are AI collaborators with real skills and memory. They get better at helping you over time."  
- Button: "Create Persona"

Click action: opens the Create Persona modal (Part 9).

### Card 3: "Start a conversation" 

- Icon: MessageSquare (24px, `textFaint`)  
- Title: "Start a conversation"  
- Description: "Chat is the backbone of everything. Start talking and your workspace will grow around you."  
- Button: "New Chat"

Click action: navigate to Chat screen with a new conversation created (`/chat?new=true`).

### Onboarding Replay Link 

Below the 3 cards, a text link:

```ts
{
  marginTop: 24,
  textAlign: "center",
}
```

- "Replay the welcome tour" (12px, weight 400, `accent` color, cursor pointer, textDecoration none)  
- Click: reopens the 4-step onboarding overlay (Part 2, Section 2.4)

---

## 4.7 Partial Empty States 

Not every user arrives with zero data. Some may have personas but no instances, or chats but no tasks. The dashboard handles these partial states gracefully.

### Stat Card with Zero Count 

When a specific stat is 0, the number displays as "0" (not "—", which is reserved for loading). The card remains clickable — clicking navigates to the screen where the user can create their first entity.

### Supplemental Hint 

When exactly one stat is 0 and the rest are non-zero, a small hint appears below the stats grid:

```ts
{
  marginTop: -40,              // Pull up into the marginBottom 56px gap
  marginBottom: 32,
  fontSize: 12,
  color: theme.textFaint,
  fontWeight: 300,
}
```

| Condition | Hint Text |
| :---- | :---- |
| instances \=== 0, others \> 0 | "Create an Instance to organize your conversations." |
| personas \=== 0, others \> 0 | "Create a Persona to unlock AI collaboration." |
| chats \=== 0, others \> 0 | "Start a conversation to begin working with your Personas." |
| tasks \=== 0, others \> 0 | (No hint — tasks are optional and naturally zero) |

If more than one stat is 0, no hint is shown (the full empty state or the normal view handles this).

---

## 4.8 Dashboard Data Freshness 

### Polling Strategy 

The dashboard does not use aggressive polling. Instead it relies on:

1. **React Query staleTime:** Stats (30s), Activity (15s). Data is considered fresh within these windows.  
2. **refetchOnWindowFocus:** Both queries refetch when the user returns to the browser tab.  
3. **Realtime subscription:** New activity log entries appear live (Section 4.5).  
4. **Navigation refetch:** React Query automatically refetches stale queries when the user navigates to the dashboard from another screen.

### Cache Invalidation 

When the user performs an action on another screen that would change dashboard stats (creating an instance, persona, chat, or completing a task), the action handler should invalidate the dashboard stats cache:

```ts
// After creating an instance on the Spaces screen:
queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] });
```

This ensures the stats are fresh when the user next visits the dashboard.

---

*End of Part 4\. Proceed to Part 5: Chat System.*

# 

# PART 5: CHAT SYSTEM (Core) 

---

The chat system is the backbone of aiConnected. Every interaction — whether the user is managing projects, browsing the web, or coordinating personas — ultimately flows through chat. This is the single most complex part of the platform and must be built with precision.

---

## 5.1 Data Model 

### Chats Table 

```sql
CREATE TABLE public.chats (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  instance_id UUID REFERENCES public.instances(id) ON DELETE SET NULL,
  title TEXT NOT NULL DEFAULT 'New Chat',
  auto_title TEXT,                              -- Cipher-suggested rename (displayed in banner)
  tags TEXT[] DEFAULT '{}',
  pinned BOOLEAN NOT NULL DEFAULT false,
  archived_at TIMESTAMPTZ,
  deleted_at TIMESTAMPTZ,                       -- Soft delete, 30-day retention
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.chats ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own chats"
  ON public.chats FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_chats_user_updated ON public.chats(user_id, updated_at DESC);
CREATE INDEX idx_chats_instance ON public.chats(instance_id) WHERE instance_id IS NOT NULL;
CREATE INDEX idx_chats_deleted ON public.chats(user_id, deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_chats_fulltext ON public.chats USING gin(to_tsvector('english', title));

-- Auto-update
CREATE TRIGGER set_chats_updated_at
  BEFORE UPDATE ON public.chats FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### Chat Participants Table 

Tracks which personas are participating in each chat. This is a join table, not an array column, to support efficient querying and real-time subscriptions.

```sql
CREATE TABLE public.chat_participants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  chat_id UUID NOT NULL REFERENCES public.chats(id) ON DELETE CASCADE,
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  removed_at TIMESTAMPTZ,                       -- Null = currently active
  UNIQUE(chat_id, persona_id, added_at)
);

ALTER TABLE public.chat_participants ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage participants in own chats"
  ON public.chat_participants FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.chats
      WHERE chats.id = chat_participants.chat_id
      AND chats.user_id = auth.uid()
    )
  );

CREATE INDEX idx_chat_participants_chat ON public.chat_participants(chat_id) WHERE removed_at IS NULL;
CREATE INDEX idx_chat_participants_persona ON public.chat_participants(persona_id) WHERE removed_at IS NULL;
```

### Messages Table 

```sql
CREATE TABLE public.messages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  chat_id UUID NOT NULL REFERENCES public.chats(id) ON DELETE CASCADE,
  sender_type TEXT NOT NULL CHECK (sender_type IN ('user', 'persona', 'system', 'cipher')),
  sender_id UUID,                               -- persona_id for persona messages, null for user/system
  sender_name TEXT NOT NULL,                     -- Denormalized for display: "You", "Sally", "System"
  content TEXT NOT NULL DEFAULT '',
  content_blocks JSONB DEFAULT '[]'::jsonb,     -- Structured content: inline components, tool outputs
  model_used TEXT,                               -- OpenRouter model ID used for generation
  routing_reason TEXT,                           -- Why Cipher selected this persona: "skill_match", "mention", "context", "default"
  token_count INTEGER,                           -- Total tokens (prompt + completion)
  latency_ms INTEGER,                            -- Response generation time
  pinned BOOLEAN NOT NULL DEFAULT false,
  has_link BOOLEAN NOT NULL DEFAULT false,       -- Contains URL(s) — used for filtering
  has_media BOOLEAN NOT NULL DEFAULT false,      -- Contains image/file — used for filtering
  metadata JSONB DEFAULT '{}'::jsonb,            -- Additional data: memories_saved, forked_from, etc.
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view messages in own chats"
  ON public.messages FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM public.chats
      WHERE chats.id = messages.chat_id
      AND chats.user_id = auth.uid()
    )
  );

CREATE POLICY "Users insert messages in own chats"
  ON public.messages FOR INSERT
  WITH CHECK (
    EXISTS (
      SELECT 1 FROM public.chats
      WHERE chats.id = messages.chat_id
      AND chats.user_id = auth.uid()
    )
  );

CREATE INDEX idx_messages_chat_created ON public.messages(chat_id, created_at);
CREATE INDEX idx_messages_pinned ON public.messages(chat_id) WHERE pinned = true;
CREATE INDEX idx_messages_fulltext ON public.messages USING gin(to_tsvector('english', content));
```

### Content Blocks Schema 

The `content_blocks` JSONB column supports structured content rendered inline within messages. This is how Cipher delivers server-driven UI components.

```ts
type ContentBlock =
  | { type: "text"; content: string }
  | { type: "task_card"; title: string; time_estimate: string; status: "ready" | "pending" | "done" }
  | { type: "code_block"; language: string; code: string }
  | { type: "file_reference"; file_id: string; name: string; type: string; size: string }
  | { type: "comparison_table"; headers: string[]; rows: string[][] }
  | { type: "image"; url: string; alt: string; width?: number; height?: number }
  | { type: "pricing_table"; tiers: { name: string; price: string; features: string[] }[] }
  | { type: "link_preview"; url: string; title: string; description: string; image?: string };
```

When `content_blocks` is non-empty, the message renderer iterates over the blocks and renders each according to its type. The `content` field (plain text) serves as the fallback/accessible version.

### Soft Delete & Recently Deleted 

When a user deletes a chat, `deleted_at` is set to `now()`. The chat remains in the database for 30 days, during which:

- It appears in the "Recently Deleted" section of the chat drawer  
- It can be restored (set `deleted_at` to NULL)  
- It can be permanently deleted (hard delete from database)

A scheduled Supabase function runs daily to permanently delete chats where `deleted_at < now() - interval '30 days'`.

```sql
-- Scheduled cleanup (runs via pg_cron or Supabase scheduled function)
DELETE FROM public.chats
WHERE deleted_at IS NOT NULL
AND deleted_at < now() - interval '30 days';
```

### TypeScript Types 

```ts
// src/types/chat.ts
interface Chat {
  id: string;
  user_id: string;
  instance_id: string | null;
  title: string;
  auto_title: string | null;
  tags: string[];
  pinned: boolean;
  archived_at: string | null;
  deleted_at: string | null;
  created_at: string;
  updated_at: string;
  // Joined data
  participants?: ChatParticipant[];
  last_message?: Message;
}

interface ChatParticipant {
  id: string;
  chat_id: string;
  persona_id: string;
  persona?: Persona;              // Joined
  added_at: string;
  removed_at: string | null;
}

interface Message {
  id: string;
  chat_id: string;
  sender_type: "user" | "persona" | "system" | "cipher";
  sender_id: string | null;
  sender_name: string;
  content: string;
  content_blocks: ContentBlock[];
  model_used: string | null;
  routing_reason: string | null;
  token_count: number | null;
  latency_ms: number | null;
  pinned: boolean;
  has_link: boolean;
  has_media: boolean;
  metadata: Record<string, unknown>;
  created_at: string;
}
```

---

## 5.2 Chat List Drawer (Left Panel) 

The Chat Drawer is a collapsible left panel that lists all of the user's conversations, grouped by Instance.

### Dimensions & Behavior 

| Context | Width | Behavior |
| :---- | :---- | :---- |
| Desktop, open | 280px | Fixed panel, `flexShrink: 0`, border-right |
| Desktop, closed | 0px | Not rendered |
| Mobile, open | 100% viewport | Fixed overlay, z-index 40, full-screen |
| Mobile, closed | 0px | Not rendered |

**Default state:** Closed. Toggled via the MessageSquare icon in the top bar (desktop) or the Inbox icon (mobile).

### Header 

```ts
{
  padding: "14px 16px 8px",
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  flexShrink: 0,
}
```

- Title: "Conversations" (13px, weight 500\)  
- Action buttons (right side):  
  - Multi-select toggle: Square icon (13px), accent color when active, `textFaint` when inactive  
  - New chat: Plus icon (15px, `textMuted`)  
  - Close (mobile only): X icon (15px, `textMuted`)

### Multi-Select Mode 

When multi-select is active, each chat item shows a checkbox on the left. Selected chats show a filled accent-color checkbox with a white checkmark (9px, weight 700).

**Batch action bar:** Appears below the header when `selected.length > 0`:

```ts
{
  padding: "6px 12px",
  display: "flex",
  gap: 4,
  borderBottom: `1px solid ${theme.borderSubtle}`,
}
```

- Selected count: ``{n}`` (10px, weight 450\)  
- Move button: ArrowRight icon (9px) \+ "Move" (9px)  
- Archive button: Archive icon (9px) \+ "Archive" (9px)  
- Delete button: Trash2 icon (9px) \+ "Delete" (9px, color \#ef4444)

All batch buttons share: `border: 1px solid theme.border, borderRadius: 5, padding: 2px 7px`.

### Search Bar 

Below the header, always visible:

```ts
{
  display: "flex",
  alignItems: "center",
  background: theme.surfaceAlt,
  borderRadius: 8,
  padding: "6px 10px",
}
```

- Search icon (12px, `textFaint`)  
- Input: "Search chats..." placeholder (11px, weight 350\)  
- Filtering is real-time on chat titles

### Chat List (Grouped by Instance) 

Chats are grouped by their parent Instance. The group key is the Instance name, or "General" for chats with no instance (`instance_id IS NULL`).

**Group header:**

```ts
{
  padding: "10px 16px 4px",
  fontSize: 10,
  fontWeight: 500,
  color: theme.textFaint,
  textTransform: "uppercase",
  letterSpacing: "0.06em",
}
```

**Chat item:**

```ts
{
  padding: "9px 16px",
  cursor: "pointer",
  display: "flex",
  alignItems: "flex-start",
  gap: 8,
  background: isActive ? theme.surfaceAlt : "transparent",
  borderLeft: isActive ? `2px solid ${theme.accent}` : "2px solid transparent",
}
```

Each chat item shows:

- Title: 12px, weight 500 (active) or 400 (inactive), max-width 140px, text-overflow ellipsis  
- Hover actions (right side, visible on hover only): Archive (10px) and Delete (10px) icon buttons, both `textFaint`  
- Timestamp: 10px, weight 300, `textFaint`  
- Preview text: 11px, weight 300, `textMuted`, single-line ellipsis  
- Persona avatars: row of 14px Avatar circles for each participating persona  
- Tags: row of hashtag chips (9px, `textFaint`, `surfaceAlt` background, borderRadius 4px)

**Data query:**

```ts
const { data: chats } = useQuery({
  queryKey: ["chats", userId],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("chats")
      .select(`
        *,
        participants:chat_participants(
          persona:personas(id, name, status)
        ),
        messages(content, created_at)
      `)
      .is("deleted_at", null)
      .order("updated_at", { ascending: false })
      .limit(1, { foreignTable: "messages" });
    return data ?? [];
  },
});
```

### Recently Deleted Section 

At the bottom of the chat list, below a thin divider:

**Toggle header:**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 6,
  fontSize: 11,
  color: theme.textFaint,
  fontWeight: 400,
  cursor: "pointer",
}
```

- Trash2 icon (11px)  
- "Recently Deleted" text  
- Count badge (10px, `surfaceAlt` background, borderRadius 8\)  
- Chevron (10px) that rotates 90° when expanded (transition: 0.15s)

**Deleted chat items:**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  padding: "7px 0 7px 17px",
  marginTop: 4,
}
```

- Title: 11px, weight 350, `textMuted`, ellipsis  
- Expiry text: `Expires {n} days` (9px, weight 300, `textFaint`)  
- Restore button: RotateCcw icon (10px, `textFaint`)  
- Permanent delete button: Trash2 icon (10px, `#ef4444`, opacity 0.6)

**Data query:**

```ts
const { data: deletedChats } = useQuery({
  queryKey: ["deleted-chats", userId],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("chats")
      .select("id, title, deleted_at")
      .not("deleted_at", "is", null)
      .gt("deleted_at", new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString())
      .order("deleted_at", { ascending: false });
    return data ?? [];
  },
});
```

---

## 5.3 Message List 

The message list occupies the center of the chat screen — it is the primary content area.

### Container 

```ts
{
  flex: 1,
  overflowY: "auto",
  padding: `12px ${px}px`,     // 12px top/bottom, 48px sides (desktop), 20px (mobile)
}
```

### Banner Area (Above Messages) 

Two optional banners appear at the top, above the message list:

#### Auto-Rename Banner 

Appears when Cipher has generated a suggested title (stored in `chats.auto_title`).

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 10,
  padding: "8px 12px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  marginBottom: 16,
}
```

- Sparkles icon (13px, `textMuted`)  
- Text: ``Rename to "{auto_title}"?`` (12px, weight 350, `textSec`). The suggested title is in `<strong>` with weight 500\.  
- "Apply" button: accent bg, 11px, weight 450, borderRadius 6, padding 3px 10px. Clicking updates `chats.title` to the `auto_title` value and clears `auto_title`.  
- Dismiss (X): 12px, `textFaint`. Clicking clears `auto_title` without applying.

**Trigger:** Cipher runs a title analysis after the 3rd message exchange (6+ messages total). The Edge Function updates `chats.auto_title`, which triggers a Realtime event that the UI picks up.

#### Suggested Move Banner 

Appears when Cipher detects the chat's content matches an existing Instance.

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 10,
  padding: "8px 12px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  marginBottom: 16,
  border: `1px solid ${theme.borderSubtle}`,
}
```

- ArrowRight icon (13px, `textMuted`)  
- Text: ``This looks like it belongs in "{instance_name}"`` (12px, weight 350, `textSec`)  
- "Move" button: accent bg, moves chat to the suggested instance  
- "Choose" button: bordered, opens instance picker to select a different destination  
- Dismiss (X): clears the suggestion

**Trigger:** Cipher runs an instance-match analysis after the 5th user message. Stored in `chats.metadata.suggested_instance_id`.

### Message Rendering 

Each message is a block with `marginBottom: 22px`:

```ts
// Base message container
{
  marginBottom: 22,
  position: "relative",
}

// Pinned message overlay
if (message.pinned) {
  background: theme.pinBg,
  margin: "0 -10px 22px",
  padding: "10px",
  borderRadius: 8,
  borderLeft: `2px solid ${theme.pinBorder}`,
}
```

#### Cipher Routing Note (Optional) 

Displayed above persona messages when the user has "Show routing notes" enabled in Settings.

```ts
{
  paddingLeft: 34,
  marginBottom: 4,
  display: "flex",
  alignItems: "center",
  gap: 5,
}
```

- Zap icon (9px, `textFaint`)  
- Text: ``Routed to {persona_name} ({persona_role})`` (10px, weight 300, `textFaint`, italic)

Only shown for `sender_type === 'persona'` messages when `routing_reason` is not null.

#### Message Header Row 

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  marginBottom: 4,
}
```

- Avatar: 26px circle. For user messages: user initial. For persona messages: persona initial with a 1.5px border in the persona's status color.  
- Sender name: 12px, weight 500, `text` color  
- Role (persona only): 10px, weight 300, `textFaint`  
- Timestamp: 10px, weight 300, `textFaint`  
- Pin icon: Pin (9px, `textMuted`) — shown only if message is pinned

#### Hover Action Bar 

Appears on the right side of the header row on message hover. `marginLeft: auto`.

```ts
// Action button (shared)
{
  background: theme.surfaceAlt,
  border: "none",
  borderRadius: 4,
  padding: 3,
  cursor: "pointer",
  color: theme.textMuted,
  display: "flex",
}
```

4 actions:

| Icon | Tooltip | Action |
| :---- | :---- | :---- |
| Pin (11px) | "Pin" | Toggle `messages.pinned` |
| Copy (11px) | "Copy" | Copy message text to clipboard |
| GitFork (11px) | "Fork" | Create a new chat branching from this message |
| MoreHorizontal (11px) | "More" | Open context menu with: Edit, Delete, Forward to Instance |

#### Message Content 

```ts
{
  fontSize: 14,
  lineHeight: 1.7,
  color: theme.textSec,
  paddingLeft: 34,              // Indent to align with header text, past avatar
  fontWeight: 350,
}
```

Content is rendered as plain text in v1. Future: Markdown rendering with code blocks, lists, emphasis.

#### Memory Extraction Indicator (Optional) 

Displayed below qualifying messages when "Show memory extraction" is enabled.

```ts
{
  paddingLeft: 34,
  marginTop: 4,
  display: "flex",
  alignItems: "center",
  gap: 4,
}
```

- Brain icon (9px, `textFaint`)  
- Text: `{n} memory saved` or `{n} memories saved` (10px, weight 300, `textFaint`)

Shown when `message.metadata.memories_saved > 0`.

#### Inline Interactive Components (Content Blocks) 

When a message has `content_blocks`, they render below the text content at `paddingLeft: 34` with `marginTop: 10`.

**Example: Task cards**

```ts
// Grid of task cards
{
  display: "grid",
  gridTemplateColumns: "1fr 1fr",
  gap: 8,
  maxWidth: 380,
}

// Each task card
{
  padding: "10px 12px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  border: `1px solid ${theme.borderSubtle}`,
}
```

- Title: 12px, weight 450  
- Estimate: `Est: {time}` (11px, weight 300, `textMuted`)  
- Status: 10px, weight 400, colored by status (green for "Ready", amber for "Pending")  
- Footer label: "Interactive component" (9px, weight 300, `textFaint`, italic)

#### Regenerate Button 

Displayed below the last AI message only:

```ts
{
  paddingLeft: 34,
  marginTop: 6,
  display: "flex",
  gap: 5,
}
```

Button: RefreshCw icon (10px) \+ "Regenerate" text (10px). `border: 1px solid theme.border, borderRadius: 6, padding: 3px 9px, color: textMuted`.

Clicking triggers a re-generation of the last persona response using the same context but potentially a different model run.

### Empty State 

When all messages are filtered out:

```ts
{
  padding: 48,
  textAlign: "center",
  color: theme.textFaint,
  fontSize: 12,
  fontWeight: 300,
}
// Text: "No messages match this filter."
```

### Message Loading 

On initial chat load, fetch the most recent 50 messages. As the user scrolls up, load older messages in batches of 50 (infinite scroll up). Use cursor-based pagination on `messages.created_at`.

```ts
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ["messages", chatId],
  queryFn: async ({ pageParam }) => {
    const supabase = createClient();
    let query = supabase
      .from("messages")
      .select("*")
      .eq("chat_id", chatId)
      .order("created_at", { ascending: false })
      .limit(50);
    if (pageParam) {
      query = query.lt("created_at", pageParam);
    }
    const { data } = await query;
    return data ?? [];
  },
  getNextPageParam: (lastPage) => lastPage[lastPage.length - 1]?.created_at,
  initialPageParam: undefined,
});
```

Messages are reversed for display (oldest at top, newest at bottom).

---

## 5.4 Message Composer 

The composer is pinned to the bottom of the chat screen, below the message list.

### Container 

```ts
// Outer wrapper
{
  padding: `10px ${px}px 18px`,
  flexShrink: 0,
}

// Input bar
{
  display: "flex",
  alignItems: "center",
  background: theme.inputBg,
  borderRadius: 12,
  padding: "9px 12px",
  border: `1px solid ${theme.border}`,
}
```

### Left Side: Persona Selector (@mention) 

The PersonaSelector allows the user to direct a message to a specific persona or to "Everyone" (all participants).

**Trigger button:**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 3,
  background: selected ? theme.surfaceAlt : "none",
  border: selected ? `1px solid ${theme.border}` : "none",
  borderRadius: 8,
  padding: "3px 7px",
  fontSize: 11,
  color: selected ? theme.text : theme.textFaint,
  cursor: "pointer",
  fontWeight: 400,
}
```

- AtSign icon (12px) \+ selected persona name (or empty when "Everyone")

**Dropdown:** Opens upward from the button (bottom: 100%), `minWidth: 150px`, borderRadius 10, boxShadow.

Items:

- "Everyone" option: no avatar, 11px, `textMuted`  
- Each chat participant persona: 18px avatar \+ name (11px, weight 400\) \+ role (9px, `textFaint`)  
- Active persona highlighted with `surfaceAlt` background

When a persona is selected, the input placeholder changes from `Message everyone...` to `Message {persona_name}...`.

### Left Side: Model Selector 

Positioned next to the PersonaSelector with `marginLeft: 4px`.

**Trigger button:**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 3,
  background: "none",
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 6,
  padding: "2px 7px",
  fontSize: 10,
  color: theme.textMuted,
  cursor: "pointer",
  fontWeight: 400,
  whiteSpace: "nowrap",
}
```

- Cpu icon (10px) \+ current model name \+ ChevronDown (9px)

**Dropdown:** Opens upward, `minWidth: 180px`.

Header: "Switch Model" (10px, weight 500, uppercase, letter-spacing 0.05em, `textFaint`).

Each model item:

```ts
{
  padding: "6px 10px",
  borderRadius: 6,
  cursor: "pointer",
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  background: isSelected ? theme.surfaceAlt : "transparent",
}
```

- Name: 11px, weight 400  
- Provider: 9px, `textFaint`  
- Tag badge: 9px, `textMuted`, `surfaceAlt` background, borderRadius 4

**Available models (v1):**

| ID | Name | Provider | Tag |
| :---- | :---- | :---- | :---- |
| claude-opus | Claude Opus | Anthropic | Best overall |
| claude-sonnet | Claude Sonnet | Anthropic | Fast |
| gpt-4o | GPT-4o | OpenAI | Versatile |
| gemini-2.5 | Gemini 2.5 | Google | Research |
| deepseek-r1 | DeepSeek R1 | DeepSeek | Reasoning |
| minimax | Minimax | OpenRouter | Lightweight |

The selected model is used for the next message only. It can be overridden by Cipher's routing logic if multi-model routing is enabled (Premium+).

### Center: Text Input 

```ts
{
  flex: 1,
  border: "none",
  background: "none",
  outline: "none",
  fontSize: 13,
  color: theme.text,
  fontWeight: 350,
  marginLeft: 6,
}
```

- Placeholder: dynamic based on persona selection  
  - No persona selected: "Message everyone..."  
  - Persona selected: `Message {persona_name}...`  
- Submit on Enter (without Shift). Shift+Enter inserts newline.

### Right Side: Action Buttons 

Three icon buttons with `gap: 10, marginLeft: 10, flexShrink: 0`:

| Icon | Size | Color | Action |
| :---- | :---- | :---- | :---- |
| Mic | 15px | `textFaint` | Start voice input (future, v2) |
| Paperclip | 15px | `textFaint` | Open file attachment dialog |
| ArrowUp | 15px | `textMuted` | Send message |

The Send button should use `textMuted` when input is empty (disabled visual) and `accent` when text is present.

### Draft Indicator 

Appears below the input bar when the user is typing:

```ts
// Visible when cInput.length > 0
{
  display: "flex",
  alignItems: "center",
  gap: 4,
  padding: "2px 14px 0",
  opacity: 0.5,
}
```

- Green dot: 4px circle, `dot.active`  
- Text: "Draft saved" (9px, weight 300, `textFaint`)

**Draft persistence:** Drafts are saved to `localStorage` keyed by chat ID. On chat switch, the draft for the current chat is saved and the draft for the new chat is loaded. This ensures no typed text is lost when switching conversations.

```ts
// Save draft on input change
useEffect(() => {
  const timeout = setTimeout(() => {
    localStorage.setItem(`draft:${chatId}`, inputValue);
  }, 500); // Debounce 500ms
  return () => clearTimeout(timeout);
}, [inputValue, chatId]);

// Load draft on chat switch
useEffect(() => {
  const draft = localStorage.getItem(`draft:${chatId}`);
  setInputValue(draft ?? "");
}, [chatId]);
```

### Send Message Flow 

1. User types message and presses Enter (or clicks Send)  
2. Client validates: non-empty text, at least one participant  
3. Message is optimistically inserted into the message list with `sender_type: 'user'`  
4. Client calls the `chat-send` Edge Function with:

```json
{
  "chat_id": "...",
  "content": "user's message text",
  "directed_persona_id": "... or null",
  "model_override": "... or null",
  "attachments": []
}
```

5. Edge Function: a. Inserts user message into `messages` table b. Calls Cipher routing logic (see Section 5.8) c. Cipher selects responding persona and model d. Calls OpenRouter API with assembled context e. Streams response tokens back to client via `chat-stream` Edge Function  
6. Client receives streaming tokens via Supabase Realtime or SSE and renders them progressively  
7. On completion, the full persona message is inserted into `messages` table  
8. Post-response: Cipher runs memory extraction asynchronously

---

## 5.5 Auto-Rename & Suggested Move 

### Auto-Rename 

**Trigger:** After the 3rd exchange (6+ messages: 3 user \+ 3 persona). A Cipher Edge Function analyzes the conversation content and generates a concise, descriptive title.

**Implementation:**

```ts
// supabase/functions/cipher-rename/index.ts
// Called as a post-response task

const prompt = `Based on this conversation, suggest a short, descriptive title (5-8 words max).
The title should capture the main topic or decision, not be generic.
Current title: "${chat.title}"

Conversation:
${messages.map(m => `${m.sender_name}: ${m.content}`).join('\n')}

Respond with only the title, no quotes or explanation.`;

const suggestedTitle = await callModel(prompt);

await supabase
  .from("chats")
  .update({ auto_title: suggestedTitle })
  .eq("id", chatId);
```

The UI detects the `auto_title` change via Realtime subscription and shows the rename banner.

### Suggested Move 

**Trigger:** After the 5th user message in a chat with no instance (`instance_id IS NULL`). Cipher compares conversation content against existing instance names and descriptions.

**Implementation:** The Edge Function uses embedding similarity between the conversation summary and instance descriptions. If similarity exceeds a threshold (0.7), it suggests the best-matching instance.

The suggestion is stored in `chats.metadata.suggested_instance_id` and the UI renders the move banner.

---

## 5.6 Right Panel 

The right panel is a 250px-wide sidebar on the right side of the chat screen. Desktop only. Toggled via the PanelRightOpen/Close icon in the top bar.

**Default state:** Closed.

### Tab Navigation 

4 tabs in a horizontal row at the top:

```ts
{
  display: "flex",
  borderBottom: `1px solid ${theme.borderSubtle}`,
}
```

Each tab:

```ts
{
  flex: 1,
  padding: "11px 0",
  fontSize: 11,
  fontWeight: isActive ? 500 : 350,
  color: isActive ? theme.text : theme.textMuted,
  background: "none",
  border: "none",
  borderBottom: isActive ? `1.5px solid ${theme.accent}` : "1.5px solid transparent",
  cursor: "pointer",
}
```

Tabs: Nav, Filters (with active filter dot indicator), Pinned, People.

The Filters tab shows a 5px accent-colored dot indicator next to the label when any non-"All" filter is active.

### Nav Tab: Chat Navigator 

A chronological conversation map organized by date.

**Search input:** At the top, `surfaceAlt` background, 10px font, "Search nav..." placeholder. Filters nav items in real-time by label and summary text.

**Date groups:**

```ts
// Date header
{
  padding: "10px 14px 3px",
  fontSize: 10,
  fontWeight: 500,
  color: theme.textFaint,
  letterSpacing: "0.06em",
}
```

**Nav items:** Expandable items within each date group.

```ts
// Nav item (collapsed)
{
  padding: "7px 14px",
  cursor: "pointer",
  display: "flex",
  alignItems: "flex-start",
  gap: 5,
}
```

- Chevron (11px, `textFaint`, rotates 90° when expanded, transition 0.15s)  
- Label: 11px, weight 400, `textSec`, line-height 1.4

**Expanded summary:**

```ts
{
  padding: "0 14px 6px 30px",  // Indent past chevron
  fontSize: 10,
  color: theme.textMuted,
  fontWeight: 300,
  lineHeight: 1.5,
}
```

**Data source:** Nav sections are generated by Cipher's checkpoint system (see Part 15: CogniGraph). Each checkpoint produces a label and summary that populate the nav panel. In v1, these can be generated by a post-response Edge Function that summarizes the conversation in segments.

### Filters Tab 

Contains all message filtering controls, moved from the main chat area to declutter the message view.

**Section: Message Filters**

Header: "MESSAGE FILTERS" (10px, weight 500, `textFaint`, uppercase, letter-spacing 0.06em).

6 filter chips in a wrapping flex row (gap 4px):

| Chip | ID |
| :---- | :---- |
| All | all |
| Sent | sent |
| Received | received |
| Pinned | pinned |
| Links | links |
| Media | media |

Each chip:

```ts
{
  padding: "4px 10px",
  borderRadius: 16,
  fontSize: 10,
  cursor: "pointer",
  fontWeight: 400,
  border: "none",
  background: isActive ? theme.accent : theme.surfaceAlt,
  color: isActive ? theme.accentText : theme.textMuted,
  transition: "all 0.15s",
}
```

**Filter logic:** Chips use union semantics. "All" resets all others. Multiple non-All chips mean a message matches if it matches ANY active filter. If all non-All chips are deselected, "All" auto-activates.

**Filter count:** Shown below chips when any filter is active:

```
Showing `{filteredCount}` of `{totalCount}` messages
```

(10px, weight 300, `textFaint`)

**Section: Search in Chat**

Header: "SEARCH IN CHAT" (same style as above).

Input field: `surfaceAlt` background, Search icon, 11px, clearable (X icon when text present).

Searches message content in real-time (client-side filter on already-loaded messages).

**Section: Actions**

Header: "ACTIONS".

"Export Conversation" button: FileDown icon (12px) \+ text (11px). `border: 1px solid theme.border, borderRadius: 8, padding: 8px 12px`. Opens the Export Modal.

### Pinned Tab 

Lists all pinned messages from the current chat.

**Empty state:** "No pinned messages yet. Hover and click the pin icon." (11px, weight 300, `textFaint`, centered, padding 36px 16px)

**Pinned message items:**

```ts
{
  padding: "10px 16px",
  borderBottom: `1px solid ${theme.borderSubtle}`,
}
```

- Sender \+ time: 10px, weight 300, `textFaint`, format: ``{sender} · {time}``  
- Preview: 11px, weight 350, `textSec`, line-height 1.4, truncated to 100 characters

Clicking a pinned message scrolls the main message list to that message.

### People Tab 

Manages chat participants and shows persona details.

**Section: "In This Chat"**

Header: "IN THIS CHAT" (10px, weight 500, `textFaint`, uppercase).

Participant chips in a wrapping flex row (gap 4px):

**User chip (non-removable):**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 4,
  padding: "3px 8px 3px 3px",
  background: theme.surfaceAlt,
  borderRadius: 10,
}
```

- 16px avatar \+ "You" (10px, weight 400\)

**Persona chips (removable):**

Same layout as user chip, plus an X icon (8px, `textFaint`) for removal. Clicking X calls:

```sql
UPDATE chat_participants SET removed_at = now()
WHERE chat_id = ? AND persona_id = ? AND removed_at IS NULL;
```

**Add button:**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 2,
  background: "none",
  border: `1px dashed ${theme.border}`,
  borderRadius: 10,
  padding: "3px 8px",
  fontSize: 10,
  color: theme.textMuted,
  cursor: "pointer",
}
```

- UserPlus icon (9px) \+ "Add"  
- Clicking opens a persona picker dropdown showing personas not yet in the chat

**Section: Persona Detail Cards**

Below the participants section, separated by a thin divider.

Each persona in the chat gets a clickable card:

```ts
{
  padding: "9px 14px",
  display: "flex",
  alignItems: "center",
  gap: 8,
  cursor: "pointer",
}
```

- 24px avatar  
- Name: 12px, weight 450  
- Role: 10px, weight 300, `textMuted`  
- Status dot: 6px, right-aligned

Clicking navigates to the persona's detail page (`go("people", { persona: p.id })`).

---

## 5.7 Export System 

Chat export is accessed from the Filters tab's "Export Conversation" button or via ⌘E.

The Export Modal UI is defined in Part 3, Section 3.7. Here we specify the backend implementation.

### Export Edge Function 

```ts
// supabase/functions/chat-export/index.ts

interface ExportRequest {
  chat_id: string;
  format: "markdown" | "json" | "pdf" | "html" | "encrypted_zip";
  scope: "full" | "filtered";
  filters?: {
    sent?: boolean;
    received?: boolean;
    pinned?: boolean;
    links?: boolean;
    media?: boolean;
    search_query?: string;
  };
}
```

**Format implementations:**

| Format | Implementation | Output |
| :---- | :---- | :---- |
| Markdown | Template: `# {title}\n\n{messages as "**Sender** (time): content"}` | `.md` file download |
| JSON | Structured export: `{ chat, messages, participants, metadata }` | `.json` file download |
| PDF | Generate via a PDF library (e.g., `jspdf` or server-side `puppeteer`) | `.pdf` file download |
| HTML | Self-contained HTML with inline styles matching the app theme | `.html` file download |
| Encrypted ZIP | All formats bundled \+ AES-256 encrypted with user-provided passphrase | `.zip` file download |

### Export History 

```sql
CREATE TABLE public.export_history (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  chat_id UUID NOT NULL REFERENCES public.chats(id) ON DELETE CASCADE,
  format TEXT NOT NULL,
  scope TEXT NOT NULL,
  file_size_bytes INTEGER,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.export_history ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own exports"
  ON public.export_history FOR SELECT
  USING (auth.uid() = user_id);
```

---

## 5.8 AI Response Pipeline 

This is the server-side flow from "user sends message" to "persona response appears." This is the core of the Cipher orchestration engine applied specifically to chat.

### Flow Diagram 

```
User sends message
        │
        ▼
┌───────────────────┐
│  1. INSERT user    │
│     message        │
└────────┬──────────┘
         │
         ▼
┌───────────────────┐
│  2. Cipher ROUTE   │  Determine which persona responds
│     - @mention?    │  and which model to use
│     - Skill match? │
│     - Context?     │
│     - Default?     │
└────────┬──────────┘
         │
         ▼
┌───────────────────┐
│  3. Context        │  Build the prompt with:
│     ASSEMBLY       │  - Persona identity (system prompt)
│                    │  - Relevant CogniGraph memories
│                    │  - Recent messages (with checkpoints)
│                    │  - Instance context
│                    │  - Token budget management
└────────┬──────────┘
         │
         ▼
┌───────────────────┐
│  4. MODEL CALL     │  Stream response via OpenRouter
│     (streaming)    │
└────────┬──────────┘
         │
         ▼
┌───────────────────┐
│  5. INSERT persona │  Save complete response
│     message        │
└────────┬──────────┘
         │
         ▼
┌───────────────────┐
│  6. POST-RESPONSE  │  Async background tasks:
│     TASKS          │  - Memory extraction
│                    │  - Auto-rename check
│                    │  - Suggested move check
│                    │  - Activity log update
└───────────────────┘
```

### Step 2: Cipher Routing 

The routing algorithm determines which persona responds to the user's message. Priority order:

1. **Explicit @mention:** If the user selected a specific persona in the PersonaSelector, route to that persona. No ambiguity. This always wins.  
     
2. **Skill matching:** If no explicit mention, analyze the message content. Extract intent keywords. Compare against each participant persona's skill set. Select the persona with the highest skill relevance score.  
     
3. **Conversation context:** If skill matching is ambiguous (multiple personas score similarly), prefer the persona who responded most recently in this conversation (continuation bias).  
     
4. **Instance default:** If the chat belongs to an instance with a designated primary persona, fall back to that persona.  
     
5. **Global default:** If no other signal applies, use the user's default persona (first persona in their account, or the last-used persona).

**Routing metadata:** The selected persona and reason are recorded in `messages.routing_reason` on the persona's response message.

### Step 3: Context Assembly 

Cipher assembles the full prompt from multiple sources:

```ts
interface ContextWindow {
  system_prompt: string;       // Persona identity, skills, boundaries, instructions
  memories: string;            // Relevant CogniGraph nodes (semantic search)
  conversation: string;        // Recent messages + checkpoint summaries
  instance_context: string;    // Instance description, settings, recent activity
  user_message: string;        // The current user input
}
```

**Token budget management:**

| Component | Budget Allocation |
| :---- | :---- |
| System prompt (persona) | 500–1,500 tokens |
| Memories | Up to 2,000 tokens |
| Conversation history | Up to model context limit minus other components |
| Instance context | 200–500 tokens |
| User message | As-is |

The assembly function works backward from the model's context limit:

1. Reserve space for system prompt \+ user message (fixed)  
2. Fill memories via semantic search, sorted by relevance (up to budget)  
3. Fill conversation history newest-first, using checkpoint summaries for older segments  
4. Add instance context if space remains

### Step 4: Model Call 

The Edge Function calls the OpenRouter API using either the platform key or the user's BYOK key:

```ts
// supabase/functions/model-complete/index.ts
import OpenAI from "openai";

const client = new OpenAI({
  apiKey: userByokKey ?? process.env.OPENROUTER_API_KEY,
  baseURL: "https://openrouter.ai/api/v1",
});

const stream = await client.chat.completions.create({
  model: selectedModelId,
  messages: assembledContext,
  stream: true,
  max_tokens: 4096,
});
```

**Streaming:** Response tokens are streamed back to the client in real-time. The client renders tokens as they arrive, creating a progressive typing effect.

**Streaming delivery options:**

1. **Server-Sent Events (SSE):** The Edge Function responds with `Content-Type: text/event-stream`. The client reads via `EventSource` or `fetch` with `ReadableStream`.  
2. **Supabase Realtime:** Tokens are broadcast to a Realtime channel (`chat:{chat_id}:stream`). The client subscribes and renders.

Option 1 (SSE) is recommended for v1 as it's simpler and more reliable.

### Step 5: Save Response 

Once streaming completes, the full response is inserted into the `messages` table:

```ts
await supabase.from("messages").insert({
  chat_id: chatId,
  sender_type: "persona",
  sender_id: personaId,
  sender_name: persona.name,
  content: fullResponseText,
  content_blocks: extractedBlocks,    // If Cipher detected structured content
  model_used: modelId,
  routing_reason: routingReason,
  token_count: usage.total_tokens,
  latency_ms: endTime - startTime,
  has_link: containsUrl(fullResponseText),
  has_media: false,
  metadata: {},
});
```

### Step 6: Post-Response Tasks 

These run asynchronously after the response is saved — they do not block the user experience.

1. **Memory extraction:** Cipher analyzes the conversation exchange and extracts any new knowledge to store in CogniGraph (see Part 15). The count is stored in `messages.metadata.memories_saved` on the relevant messages.  
     
2. **Auto-rename check:** If the chat has 6+ messages and `auto_title` is null, generate a title suggestion.  
     
3. **Suggested move check:** If the chat has 10+ messages, no instance, and no prior suggestion dismissed, check for instance matching.  
     
4. **Activity log:** Insert an activity entry: `{persona_name} {action summary}` in the `activity_log` table.  
     
5. **Update chat timestamp:** `UPDATE chats SET updated_at = now() WHERE id = chat_id`.

### Error Handling & Retry 

| Error | Action |
| :---- | :---- |
| Model API timeout (30s) | Retry once with same model. If still fails, try fallback model (Claude Sonnet). |
| Model API 429 (rate limit) | Wait 2 seconds, retry once. If still fails, show user error. |
| Model API 500 | Show user error with "Retry" button. |
| Invalid API key (BYOK) | Show error: "Your API key was rejected. Please check your key in Settings." Update `api_keys.status` to 'invalid'. |
| Network error | Show retry button. No automatic retry. |
| Context too long | Truncate oldest conversation history and retry. Log a warning. |

**Stop generation:** During streaming, the user can click a "Stop" button (appears in place of the Send button). This sends an abort signal to the SSE connection. The partial response is saved as-is with `metadata.stopped = true`.

**Typing indicator:** While the AI is generating a response, the message list shows a typing indicator below the last message:

```ts
// Typing indicator (visible during streaming)
<div style={{ paddingLeft: 34, display: "flex", alignItems: "center", gap: 6 }}>
  <Avatar name={respondingPersona.name} size={20} />
  <span style={{ fontSize: 11, color: theme.textMuted, fontWeight: 350 }}>
    {respondingPersona.name} is typing...
  </span>
  <span className="typing-dots" /> {/* CSS animated dots */}
</div>
```

---

*End of Part 5\. Proceed to Part 6: Search Screen.*

# PART 6: SEARCH SCREEN 

---

Search is the universal access layer of aiConnected. It spans every data type in the platform — web results, chat history, files, CogniGraph memories, and internal entities — and presents them through a single, unified interface. The user types a query once; aiConnected searches everywhere simultaneously and surfaces the best results regardless of source.

The Search screen is also accessible as a quick-launch overlay via ⌘K from any screen. In overlay mode, the same search input and filter chips appear in a centered modal. Selecting a result from the overlay navigates to it directly. The full Search screen (accessed via the sidebar) provides the complete experience with search history, saved searches, and richer result cards.

---

## 6.1 Layout 

### Container 

Centered single-column layout, same container pattern as Home:

```ts
{
  padding: `${py}px ${px}px`,    // 40px 48px desktop, 24px 20px mobile
  overflowY: "auto",
  height: "100%",
}

// Inner wrapper
{
  maxWidth: 700,
  margin: "0 auto",
}
```

### Content Flow 

The screen has three vertical zones that swap based on state:

1. **Always visible:** Search input \+ Instance scope indicator (if scoped) \+ Filter chips  
2. **When input is empty:** Search history \+ Saved searches  
3. **When input has text:** Search results (or loading/empty state)

---

## 6.2 Search Input 

### Input Container 

```ts
{
  display: "flex",
  alignItems: "center",
  background: theme.inputBg,
  borderRadius: 12,
  padding: "14px 18px",
  border: `1px solid ${theme.border}`,
  marginBottom: 16,
  transition: "border-color 0.15s ease",
}

// On focus-within
{
  borderColor: theme.accent,
}
```

### Input Elements 

**Search icon** (left):

```ts
{
  color: theme.textMuted,
  marginRight: 12,
  flexShrink: 0,
}
// <Search size={16} />
```

**Text input** (center):

```ts
{
  flex: 1,
  border: "none",
  background: "none",
  outline: "none",
  fontSize: 15,
  color: theme.text,
  fontWeight: 350,
}
// placeholder: "Search everything..."
```

**Clear button** (right, only visible when input has text):

```ts
{
  background: "none",
  border: "none",
  color: theme.textFaint,
  cursor: "pointer",
  display: "flex",
  padding: 2,
  flexShrink: 0,
  marginRight: 8,
}
// <X size={14} />
// onClick: clear input, show search history
```

**Keyboard hint** (right):

```ts
{
  fontSize: 11,
  color: theme.textFaint,
  fontWeight: 300,
  display: "flex",
  alignItems: "center",
  gap: 2,
  flexShrink: 0,
}
// <Command size={11} /> "K"
```

The keyboard hint is hidden when the input has focus (it's a reminder for unfocused state).

### Input Behavior 

```ts
const [searchQuery, setSearchQuery] = useState("");
const [showHistory, setShowHistory] = useState(true);
const debouncedQuery = useDebounce(searchQuery, 300); // 300ms debounce

function handleSearchInput(e: React.ChangeEvent<HTMLInputElement>) {
  const value = e.target.value;
  setSearchQuery(value);
  setShowHistory(!value); // Show history when empty, hide when typing
}
```

**Debounce:** Search execution is debounced by 300ms. The user can type freely; the search fires 300ms after they stop typing. This prevents excessive API calls.

**Auto-focus:** When navigating to the Search screen, the input is auto-focused via a `useEffect` with an `inputRef`:

```ts
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
  inputRef.current?.focus();
}, []);
```

**⌘K shortcut integration:** The global ⌘K listener (Part 3, Section 3.5) navigates to the Search screen and focuses the input. If the user is already on the Search screen, ⌘K focuses the input and selects all text for easy replacement.

### Instance Scope Indicator 

If the user navigated to Search from within an Instance context, or scoped the search manually, a scope chip appears between the input and filter chips:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 6,
  marginBottom: 12,
  padding: "5px 10px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  width: "fit-content",
}
```

- LayoutGrid icon (11px, `textMuted`)  
- Text: ``Scoped to: {instance_name}`` (11px, weight 400, `textSec`)  
- Remove button: X icon (10px, `textFaint`, cursor pointer, background none, border none, padding 1px)

Clicking the X removes the scope and returns to global search. When scoped, all search backends filter their results to entities belonging to that Instance (except web search, which is always global).

**Scoping mechanism:**

```ts
const [searchScope, setSearchScope] = useState<{
  instanceId: string;
  instanceName: string;
} | null>(null);

// Set when navigating from an Instance detail view:
// navigate('/search', { state: { scope: { instanceId: inst.id, instanceName: inst.name } } })
```

---

## 6.3 Filter Chips 

Horizontal row of filter chips below the scope indicator (or below the input if no scope):

### Container 

```ts
{
  display: "flex",
  gap: 5,
  marginBottom: 24,
  flexWrap: "wrap",
}
```

### Filter Definitions 

| ID | Label | Icon | Data Source |
| :---- | :---- | :---- | :---- |
| `all` | All | (none) | Combined results from all backends |
| `web` | Web | Globe (10px) | External web search API |
| `internal` | Internal | LayoutGrid (10px) | Instances, personas, teams, settings entities |
| `memory` | Memory | Brain (10px) | CogniGraph semantic search via pg\_vector |
| `chat` | Chats | MessageSquare (10px) | Chat titles \+ message content via full-text search |
| `file` | Files | Folder (10px) | File names \+ tags via full-text search |

### Chip Styling 

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 3,
  padding: "4px 11px",
  borderRadius: 20,
  fontSize: 11,
  cursor: "pointer",
  fontWeight: 400,
  border: "none",
  background: isActive ? theme.accent : theme.surfaceAlt,
  color: isActive ? theme.accentText : theme.textMuted,
  transition: "background 0.15s ease, color 0.15s ease",
}
```

Only one filter is active at a time (single-select). "All" is the default. Clicking a chip:

1. Sets the active filter  
2. Re-filters the displayed results client-side (if results are already loaded)  
3. If no results have been fetched yet for this filter and query, triggers a new search

### Result Count Badges 

When results are loaded, each filter chip shows a count badge indicating how many results match that source:

```ts
// Badge (inside chip, after label)
{
  fontSize: 9,
  fontWeight: 500,
  color: isActive ? "rgba(255,255,255,0.7)" : theme.textFaint,
  marginLeft: 2,
}
// Display: "({count})"
```

For "All", the badge shows the total count. For individual filters, it shows the count for that source type only. Badges appear only after the first search completes.

---

## 6.4 Search Execution 

### State Management 

```ts
interface SearchState {
  query: string;
  filter: "all" | "web" | "internal" | "memory" | "chat" | "file";
  scope: { instanceId: string; instanceName: string } | null;
  results: SearchResult[];
  resultsByType: Record<string, SearchResult[]>;
  isLoading: boolean;
  error: string | null;
  hasSearched: boolean;       // True after first search — distinguishes "no results" from "haven't searched"
}
```

### Search Request/Response Types 

```ts
interface SearchRequest {
  query: string;
  filter: "all" | "web" | "internal" | "memory" | "chat" | "file";
  instance_scope?: string;    // Instance ID for scoped search
  limit?: number;             // Default 20
}

interface SearchResult {
  id: string;
  type: "web" | "internal" | "memory" | "chat" | "file";
  title: string;
  url: string;                // Display URL or source context string
  snippet: string;            // Preview text
  score: number;              // Relevance score (0–1), normalized across backends
  entity_type?: string;       // For internal results: "instance" | "persona" | "team"
  entity_id?: string;         // UUID for navigation
  metadata: {
    instance_id?: string;
    instance_name?: string;
    persona_name?: string;
    persona_id?: string;
    chat_id?: string;
    file_type?: string;
    file_size?: number;
    memory_type?: string;     // "decision" | "fact" | "preference" | "skill" | "procedure"
    confidence?: number;      // For memory results
    created_at?: string;
  };
}

interface SearchResponse {
  results: SearchResult[];
  counts: Record<string, number>;  // { web: 5, chat: 8, file: 3, memory: 2, internal: 1 }
  query: string;
  duration_ms: number;
}
```

### React Query Hook 

```ts
// src/hooks/use-search.ts
export function useSearch(query: string, filter: string, scope: string | null) {
  return useQuery({
    queryKey: ["search", query, filter, scope],
    queryFn: async () => {
      const supabase = createClient();
      const { data, error } = await supabase.functions.invoke("search", {
        body: {
          query,
          filter,
          instance_scope: scope,
          limit: 20,
        },
      });
      if (error) throw error;
      return data as SearchResponse;
    },
    enabled: query.length >= 2,   // Don't search for single characters
    staleTime: 60_000,            // Results stay fresh for 60 seconds
    keepPreviousData: true,       // Show old results while new search loads
  });
}
```

The `enabled: query.length >= 2` guard prevents searches on single characters. The `keepPreviousData` flag ensures the previous results remain visible while a new search is loading (e.g., when the user changes the query slightly).

---

## 6.5 Edge Function: Unified Search 

### Function Entry Point 

```ts
// supabase/functions/search/index.ts
import { serve } from "https://deno.land/std/http/server.ts";
import { createClient } from "@supabase/supabase-js";

serve(async (req) => {
  const { query, filter, instance_scope, limit = 20 } = await req.json();

  // Auth
  const authHeader = req.headers.get("Authorization");
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );
  const { data: { user } } = await supabase.auth.getUser(
    authHeader?.replace("Bearer ", "") ?? ""
  );
  if (!user) return new Response("Unauthorized", { status: 401 });

  const userId = user.id;
  const startTime = Date.now();

  // Determine which backends to query
  const backends = filter === "all"
    ? ["web", "chat", "file", "memory", "internal"]
    : [filter];

  // Run all backends in parallel
  const backendResults = await Promise.allSettled(
    backends.map(backend => {
      switch (backend) {
        case "web": return searchWeb(query, limit);
        case "chat": return searchChats(supabase, userId, query, instance_scope, limit);
        case "file": return searchFiles(supabase, userId, query, instance_scope, limit);
        case "memory": return searchMemory(supabase, userId, query, instance_scope, limit);
        case "internal": return searchInternal(supabase, userId, query, instance_scope, limit);
        default: return Promise.resolve([]);
      }
    })
  );

  // Collect successful results, skip failed backends
  const allResults: SearchResult[] = [];
  const counts: Record<string, number> = {};

  backends.forEach((backend, i) => {
    const result = backendResults[i];
    if (result.status === "fulfilled") {
      const items = result.value as SearchResult[];
      allResults.push(...items);
      counts[backend] = items.length;
    } else {
      console.error(`Search backend ${backend} failed:`, result.reason);
      counts[backend] = 0;
    }
  });

  // Merge and rank
  const merged = mergeAndRank(allResults);
  const limited = merged.slice(0, limit);

  // Record search history
  await supabase.from("search_history").insert({
    user_id: userId,
    query,
    filter,
    result_count: limited.length,
  });

  // Enforce history cap (50 non-saved entries)
  await supabase.rpc("trim_search_history", { p_user_id: userId, p_max: 50 });

  return new Response(JSON.stringify({
    results: limited,
    counts,
    query,
    duration_ms: Date.now() - startTime,
  }), {
    headers: { "Content-Type": "application/json" },
  });
});
```

### Backend 1: Chat Search 

PostgreSQL full-text search across `chats.title` and `messages.content`. Returns matching chat titles and individual message excerpts.

**Full-text search indexes (created once during migration):**

```sql
-- Add tsvector column and index to chats
ALTER TABLE public.chats ADD COLUMN IF NOT EXISTS title_tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', COALESCE(title, ''))) STORED;
CREATE INDEX IF NOT EXISTS idx_chats_tsv ON public.chats USING GIN (title_tsv);

-- Add tsvector column and index to messages
ALTER TABLE public.messages ADD COLUMN IF NOT EXISTS content_tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', COALESCE(content, ''))) STORED;
CREATE INDEX IF NOT EXISTS idx_messages_tsv ON public.messages USING GIN (content_tsv);
```

**Search function:**

```ts
async function searchChats(
  supabase: SupabaseClient,
  userId: string,
  query: string,
  instanceScope: string | null,
  limit: number
): Promise<SearchResult[]> {
  const results: SearchResult[] = [];

  // 1. Search chat titles
  let chatQuery = supabase.rpc("search_chat_titles", {
    p_user_id: userId,
    p_query: query,
    p_limit: Math.ceil(limit / 2),
  });
  if (instanceScope) {
    // RPC handles scope internally via parameter
  }
  const { data: chatMatches } = await chatQuery;

  if (chatMatches) {
    for (const match of chatMatches) {
      results.push({
        id: `chat-title-${match.id}`,
        type: "chat",
        title: match.title || "Untitled conversation",
        url: match.instance_name
          ? `Chat · ${match.instance_name}`
          : "Chat",
        snippet: match.preview || "No preview available",
        score: match.rank,
        entity_id: match.id,
        metadata: {
          chat_id: match.id,
          instance_id: match.instance_id,
          instance_name: match.instance_name,
          created_at: match.updated_at,
        },
      });
    }
  }

  // 2. Search message content
  const { data: msgMatches } = await supabase.rpc("search_message_content", {
    p_user_id: userId,
    p_query: query,
    p_instance_scope: instanceScope,
    p_limit: Math.ceil(limit / 2),
  });

  if (msgMatches) {
    for (const match of msgMatches) {
      results.push({
        id: `chat-msg-${match.message_id}`,
        type: "chat",
        title: match.chat_title || "Untitled conversation",
        url: match.instance_name
          ? `Chat · ${match.instance_name}`
          : `Chat · ${match.sender_name}`,
        snippet: highlightSnippet(match.content, query, 200),
        score: match.rank * 0.9, // Slightly lower than title matches
        entity_id: match.chat_id,
        metadata: {
          chat_id: match.chat_id,
          instance_id: match.instance_id,
          instance_name: match.instance_name,
          persona_name: match.sender_name,
          created_at: match.created_at,
        },
      });
    }
  }

  return results;
}
```

**PostgreSQL RPC functions:**

```sql
-- Search chat titles
CREATE OR REPLACE FUNCTION search_chat_titles(
  p_user_id UUID,
  p_query TEXT,
  p_limit INT DEFAULT 10
)
RETURNS TABLE (
  id UUID,
  title TEXT,
  instance_id UUID,
  instance_name TEXT,
  preview TEXT,
  updated_at TIMESTAMPTZ,
  rank REAL
) LANGUAGE sql STABLE AS $$
  SELECT
    c.id,
    c.title,
    c.instance_id,
    i.name AS instance_name,
    (SELECT content FROM messages WHERE chat_id = c.id ORDER BY created_at DESC LIMIT 1) AS preview,
    c.updated_at,
    ts_rank(c.title_tsv, plainto_tsquery('english', p_query)) AS rank
  FROM chats c
  LEFT JOIN instances i ON i.id = c.instance_id
  WHERE c.user_id = p_user_id
    AND c.deleted_at IS NULL
    AND c.title_tsv @@ plainto_tsquery('english', p_query)
  ORDER BY rank DESC
  LIMIT p_limit;
$$;

-- Search message content
CREATE OR REPLACE FUNCTION search_message_content(
  p_user_id UUID,
  p_query TEXT,
  p_instance_scope UUID DEFAULT NULL,
  p_limit INT DEFAULT 10
)
RETURNS TABLE (
  message_id UUID,
  chat_id UUID,
  chat_title TEXT,
  instance_id UUID,
  instance_name TEXT,
  sender_name TEXT,
  content TEXT,
  created_at TIMESTAMPTZ,
  rank REAL
) LANGUAGE sql STABLE AS $$
  SELECT
    m.id AS message_id,
    m.chat_id,
    c.title AS chat_title,
    c.instance_id,
    i.name AS instance_name,
    COALESCE(p.name, CASE WHEN m.sender_type = 'user' THEN 'You' ELSE 'System' END) AS sender_name,
    m.content,
    m.created_at,
    ts_rank(m.content_tsv, plainto_tsquery('english', p_query)) AS rank
  FROM messages m
  JOIN chats c ON c.id = m.chat_id
  LEFT JOIN instances i ON i.id = c.instance_id
  LEFT JOIN personas p ON p.id = m.persona_id
  WHERE c.user_id = p_user_id
    AND c.deleted_at IS NULL
    AND m.content_tsv @@ plainto_tsquery('english', p_query)
    AND (p_instance_scope IS NULL OR c.instance_id = p_instance_scope)
  ORDER BY rank DESC
  LIMIT p_limit;
$$;
```

### Backend 2: File Search 

Full-text search on file names and tags:

```sql
-- Full-text index for files
ALTER TABLE public.files ADD COLUMN IF NOT EXISTS search_tsv tsvector
  GENERATED ALWAYS AS (
    to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(tags::text, ''))
  ) STORED;
CREATE INDEX IF NOT EXISTS idx_files_tsv ON public.files USING GIN (search_tsv);
```

```ts
async function searchFiles(
  supabase: SupabaseClient,
  userId: string,
  query: string,
  instanceScope: string | null,
  limit: number
): Promise<SearchResult[]> {
  const { data: matches } = await supabase.rpc("search_files", {
    p_user_id: userId,
    p_query: query,
    p_instance_scope: instanceScope,
    p_limit: limit,
  });

  if (!matches) return [];

  return matches.map((match: any) => ({
    id: `file-${match.id}`,
    type: "file" as const,
    title: match.name,
    url: [
      match.instance_name ? `Files · ${match.instance_name}` : "Files",
      formatFileSize(match.size_bytes),
    ].filter(Boolean).join(" · "),
    snippet: match.ai_summary || `${match.type.toUpperCase()} file uploaded ${relativeTime(match.created_at)}`,
    score: match.rank,
    entity_id: match.id,
    metadata: {
      instance_id: match.instance_id,
      instance_name: match.instance_name,
      file_type: match.type,
      file_size: match.size_bytes,
      created_at: match.created_at,
    },
  }));
}
```

```sql
CREATE OR REPLACE FUNCTION search_files(
  p_user_id UUID,
  p_query TEXT,
  p_instance_scope UUID DEFAULT NULL,
  p_limit INT DEFAULT 10
)
RETURNS TABLE (
  id UUID,
  name TEXT,
  type TEXT,
  size_bytes BIGINT,
  instance_id UUID,
  instance_name TEXT,
  ai_summary TEXT,
  created_at TIMESTAMPTZ,
  rank REAL
) LANGUAGE sql STABLE AS $$
  SELECT
    f.id, f.name, f.type, f.size_bytes,
    f.instance_id,
    i.name AS instance_name,
    f.metadata->>'ai_summary' AS ai_summary,
    f.created_at,
    ts_rank(f.search_tsv, plainto_tsquery('english', p_query)) AS rank
  FROM files f
  LEFT JOIN instances i ON i.id = f.instance_id
  WHERE f.user_id = p_user_id
    AND f.search_tsv @@ plainto_tsquery('english', p_query)
    AND (p_instance_scope IS NULL OR f.instance_id = p_instance_scope)
  ORDER BY rank DESC
  LIMIT p_limit;
$$;
```

### Backend 3: Memory Search (Semantic) 

Uses pg\_vector to perform semantic similarity search across CogniGraph memory embeddings. The user's query is first converted to an embedding vector, then compared against stored memory embeddings.

**Embedding generation:**

```ts
async function generateEmbedding(text: string): Promise<number[]> {
  // Use OpenRouter to call an embedding model
  const response = await fetch("https://openrouter.ai/api/v1/embeddings", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${Deno.env.get("OPENROUTER_API_KEY")}`,
    },
    body: JSON.stringify({
      model: "openai/text-embedding-3-small",
      input: text,
    }),
  });
  const data = await response.json();
  return data.data[0].embedding;
}
```

**Search function:**

```ts
async function searchMemory(
  supabase: SupabaseClient,
  userId: string,
  query: string,
  instanceScope: string | null,
  limit: number
): Promise<SearchResult[]> {
  // 1. Generate embedding for the query
  const embedding = await generateEmbedding(query);

  // 2. Semantic search via RPC
  const { data: matches } = await supabase.rpc("search_memories_semantic", {
    p_user_id: userId,
    p_embedding: JSON.stringify(embedding),
    p_instance_scope: instanceScope,
    p_similarity_threshold: 0.4,
    p_limit: limit,
  });

  if (!matches) return [];

  return matches.map((match: any) => ({
    id: `memory-${match.id}`,
    type: "memory" as const,
    title: truncate(match.content, 80),
    url: "Memory",
    snippet: `${match.persona_name}: ${match.content}. Strength: ${(match.confidence * 100).toFixed(0)}%.`,
    score: match.similarity,
    metadata: {
      persona_name: match.persona_name,
      persona_id: match.persona_id,
      memory_type: match.type,
      confidence: match.confidence,
      instance_id: match.instance_id,
      created_at: match.created_at,
    },
  }));
}
```

```sql
CREATE OR REPLACE FUNCTION search_memories_semantic(
  p_user_id UUID,
  p_embedding TEXT,              -- JSON-encoded float array
  p_instance_scope UUID DEFAULT NULL,
  p_similarity_threshold REAL DEFAULT 0.4,
  p_limit INT DEFAULT 10
)
RETURNS TABLE (
  id UUID,
  content TEXT,
  type TEXT,
  confidence REAL,
  persona_id UUID,
  persona_name TEXT,
  instance_id UUID,
  created_at TIMESTAMPTZ,
  similarity REAL
) LANGUAGE sql STABLE AS $$
  SELECT
    mn.id,
    mn.content,
    mn.type,
    mn.confidence::REAL,
    mn.persona_id,
    p.name AS persona_name,
    mn.instance_id,
    mn.created_at,
    (1 - (mn.embedding <=> p_embedding::vector))::REAL AS similarity
  FROM memory_nodes mn
  LEFT JOIN personas p ON p.id = mn.persona_id
  WHERE mn.user_id = p_user_id
    AND mn.active = true
    AND (1 - (mn.embedding <=> p_embedding::vector)) > p_similarity_threshold
    AND (p_instance_scope IS NULL OR mn.instance_id = p_instance_scope)
  ORDER BY similarity DESC
  LIMIT p_limit;
$$;
```

### Backend 4: Web Search 

Integration with an external search API. Tavily is recommended for v1 (purpose-built for AI applications). Brave Search API is the fallback option.

```ts
async function searchWeb(
  query: string,
  limit: number
): Promise<SearchResult[]> {
  try {
    const response = await fetch("https://api.tavily.com/search", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        api_key: Deno.env.get("TAVILY_API_KEY"),
        query,
        max_results: limit,
        include_answer: false,
        include_raw_content: false,
      }),
    });

    if (!response.ok) {
      throw new Error(`Tavily API error: ${response.status}`);
    }

    const data = await response.json();

    return (data.results || []).map((r: any, i: number) => ({
      id: `web-${i}-${r.url}`,
      type: "web" as const,
      title: r.title,
      url: new URL(r.url).hostname,       // Show domain only
      snippet: r.content || r.description || "",
      score: r.score ?? (1 - i * 0.05),   // Tavily provides relevance scores; fallback to position-based
      metadata: {
        full_url: r.url,
        created_at: r.published_date,
      },
    }));
  } catch (error) {
    console.error("Web search failed:", error);
    return []; // Graceful degradation — other backends still return results
  }
}
```

Web search is always global — it ignores instance scope since web content doesn't belong to any instance.

### Backend 5: Internal Search 

Cross-entity search across instances, personas, and teams. Uses simple `ILIKE` matching on names and descriptions since these are short text fields where full-text search overhead isn't justified.

```ts
async function searchInternal(
  supabase: SupabaseClient,
  userId: string,
  query: string,
  instanceScope: string | null,
  limit: number
): Promise<SearchResult[]> {
  const results: SearchResult[] = [];
  const pattern = `%${query}%`;

  // 1. Instances
  if (!instanceScope) {
    const { data: instances } = await supabase
      .from("instances")
      .select("id, name, type, description")
      .eq("user_id", userId)
      .is("archived_at", null)
      .or(`name.ilike.${pattern},description.ilike.${pattern}`)
      .limit(Math.ceil(limit / 3));

    if (instances) {
      for (const inst of instances) {
        results.push({
          id: `internal-instance-${inst.id}`,
          type: "internal",
          title: inst.name,
          url: `Instance · ${inst.type}`,
          snippet: inst.description || `${inst.type} workspace`,
          score: inst.name.toLowerCase().includes(query.toLowerCase()) ? 0.9 : 0.6,
          entity_type: "instance",
          entity_id: inst.id,
          metadata: { instance_id: inst.id },
        });
      }
    }
  }

  // 2. Personas
  const { data: personas } = await supabase
    .from("personas")
    .select("id, name, role, status")
    .eq("user_id", userId)
    .is("archived_at", null)
    .or(`name.ilike.${pattern},role.ilike.${pattern}`)
    .limit(Math.ceil(limit / 3));

  if (personas) {
    for (const p of personas) {
      results.push({
        id: `internal-persona-${p.id}`,
        type: "internal",
        title: p.name,
        url: `Persona · ${p.role}`,
        snippet: `${p.role} — Status: ${p.status}`,
        score: p.name.toLowerCase().includes(query.toLowerCase()) ? 0.85 : 0.55,
        entity_type: "persona",
        entity_id: p.id,
        metadata: { persona_id: p.id, persona_name: p.name },
      });
    }
  }

  // 3. Teams
  const { data: teams } = await supabase
    .from("teams")
    .select("id, name, type, description, status")
    .eq("user_id", userId)
    .is("archived_at", null)
    .or(`name.ilike.${pattern},description.ilike.${pattern}`)
    .limit(Math.ceil(limit / 3));

  if (teams) {
    for (const team of teams) {
      results.push({
        id: `internal-team-${team.id}`,
        type: "internal",
        title: team.name,
        url: `Team · ${team.type} · ${team.status}`,
        snippet: team.description || `${team.type} team`,
        score: team.name.toLowerCase().includes(query.toLowerCase()) ? 0.8 : 0.5,
        entity_type: "team",
        entity_id: team.id,
        metadata: {},
      });
    }
  }

  return results;
}
```

### Result Merging & Ranking 

When filter is "all", results from all backends are merged. Each backend returns scores on its own scale (full-text rank, cosine similarity, position-based). Merging normalizes these into a comparable 0–1 range.

```ts
function mergeAndRank(results: SearchResult[]): SearchResult[] {
  // 1. Group by source type
  const byType: Record<string, SearchResult[]> = {};
  for (const r of results) {
    if (!byType[r.type]) byType[r.type] = [];
    byType[r.type].push(r);
  }

  // 2. Normalize scores within each source type to 0–1
  for (const [type, items] of Object.entries(byType)) {
    const maxScore = Math.max(...items.map(i => i.score), 0.001);
    const minScore = Math.min(...items.map(i => i.score), 0);
    const range = maxScore - minScore || 1;
    for (const item of items) {
      item.score = (item.score - minScore) / range;
    }
  }

  // 3. Apply source-type boost factors
  //    Memory and chat results are boosted slightly since they represent
  //    the user's own context and are more likely what they're looking for
  const BOOST: Record<string, number> = {
    memory: 1.15,
    chat: 1.10,
    internal: 1.05,
    file: 1.0,
    web: 0.95,
  };

  for (const r of results) {
    r.score *= (BOOST[r.type] ?? 1.0);
  }

  // 4. Clamp scores to 0–1
  for (const r of results) {
    r.score = Math.min(1, Math.max(0, r.score));
  }

  // 5. Sort by score descending, with recency as tiebreaker
  return results.sort((a, b) => {
    if (Math.abs(a.score - b.score) < 0.01) {
      // Tiebreaker: more recent first
      const aTime = new Date(a.metadata.created_at || 0).getTime();
      const bTime = new Date(b.metadata.created_at || 0).getTime();
      return bTime - aTime;
    }
    return b.score - a.score;
  });
}
```

### Snippet Highlighting Utility 

For chat and file results, query terms are highlighted in the snippet:

```ts
function highlightSnippet(text: string, query: string, maxLength: number): string {
  // 1. Find the best excerpt containing the query
  const lowerText = text.toLowerCase();
  const lowerQuery = query.toLowerCase();
  const idx = lowerText.indexOf(lowerQuery);

  let excerpt: string;
  if (idx >= 0) {
    const start = Math.max(0, idx - 60);
    const end = Math.min(text.length, idx + query.length + 60);
    excerpt = (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
  } else {
    excerpt = text.slice(0, maxLength) + (text.length > maxLength ? "…" : "");
  }

  return excerpt;
}
```

On the client side, the snippet is rendered with query terms wrapped in a `<mark>` element:

```ts
function HighlightedSnippet({ text, query }: { text: string; query: string }) {
  if (!query) return <span>{text}</span>;

  const regex = new RegExp(`(${escapeRegex(query)})`, "gi");
  const parts = text.split(regex);

  return (
    <span>
      {parts.map((part, i) =>
        regex.test(part) ? (
          <mark key={i} style={{
            background: "rgba(46,149,243,0.15)",
            color: theme.text,
            borderRadius: 2,
            padding: "0 1px",
          }}>
            {part}
          </mark>
        ) : (
          <span key={i}>{part}</span>
        )
      )}
    </span>
  );
}
```

---

## 6.6 Search Results Display 

### Results Container 

```ts
{
  display: "flex",
  flexDirection: "column",
}
```

### Result Card 

Each result is a card with generous vertical spacing:

```ts
{
  marginBottom: 28,
  cursor: "pointer",
}
```

Clicking anywhere on the result card navigates to the entity (see Navigation section below). The action buttons handle their own click events with `stopPropagation`.

**Header row:**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  marginBottom: 4,
}
```

- Type icon (12px, `textFaint`):

| Type | Icon |
| :---- | :---- |
| web | Globe |
| internal | LayoutGrid |
| memory | Brain |
| chat | MessageSquare |
| file | Folder |

- Title: 16px, weight 500, `text` color. overflow hidden, textOverflow ellipsis, whiteSpace nowrap.

**URL/source line:**

```ts
{
  fontSize: 12,
  color: theme.textFaint,
  marginBottom: 8,
  fontWeight: 300,
  overflow: "hidden",
  textOverflow: "ellipsis",
  whiteSpace: "nowrap",
}
```

For web results, shows the domain (e.g., "designsystems.io"). For chat results, shows "Chat · {instance\_name}". For files, shows "Files · {instance\_name} · 2.4 MB". For memory, shows "Memory". For internal, shows "Instance · {type}" or "Persona · {role}" or "Team · {type}".

**Snippet:**

```ts
{
  fontSize: 14,
  color: theme.textSec,
  lineHeight: 1.6,
  fontWeight: 350,
  display: "-webkit-box",
  WebkitLineClamp: 3,
  WebkitBoxOrient: "vertical",
  overflow: "hidden",
}
```

The snippet is clamped to 3 lines. Uses the `HighlightedSnippet` component to highlight query terms.

**Action buttons row:**

```ts
{
  display: "flex",
  gap: 8,
  marginTop: 10,
}
```

3 action buttons per result:

```ts
{
  background: "none",
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  padding: "5px 12px",
  fontSize: 11,
  color: theme.textSec,
  cursor: "pointer",
  fontWeight: 400,
  display: "flex",
  alignItems: "center",
  gap: 4,
}
// Hover: background theme.surfaceAlt
```

| Button | Icon | Label | Action |
| :---- | :---- | :---- | :---- |
| Chat with this | MessageSquare (10px) | "Chat with this" | Creates a new chat with the search result injected as context. See Section 6.8. |
| Send to Instance | ArrowRight (10px) | "Send to Instance" | Opens an Instance picker popover. Copies/links the result to the chosen Instance's workspace. See Section 6.8. |
| Save | Bookmark (10px) | "Save" | Bookmarks the result. Icon fills with `accent` color when saved. |

The "Save" button toggles a bookmark on the result. Bookmarked results are stored in a `saved_results` table (see Section 6.10) and can be accessed later.

### Result Card Navigation 

Clicking a result card (outside of action buttons) navigates to the entity:

```ts
function handleResultClick(result: SearchResult) {
  switch (result.type) {
    case "web":
      // Open web URL in the Browser screen for co-browsing
      navigate(`/browser?url=${encodeURIComponent(result.metadata.full_url)}`);
      break;
    case "chat":
      navigate(`/chat?id=${result.metadata.chat_id}`);
      break;
    case "file":
      navigate(`/files?highlight=${result.entity_id}`);
      break;
    case "memory":
      if (result.metadata.persona_id) {
        navigate(`/people/${result.metadata.persona_id}?tab=memory`);
      }
      break;
    case "internal":
      if (result.entity_type === "instance") {
        navigate(`/spaces/${result.entity_id}`);
      } else if (result.entity_type === "persona") {
        navigate(`/people/${result.entity_id}`);
      } else if (result.entity_type === "team") {
        navigate(`/teams/${result.entity_id}`);
      }
      break;
  }
}
```

For web results, clicking navigates to the Browser screen and loads the URL in a co-browsing session. This keeps the user within aiConnected rather than opening a new browser tab.

---

## 6.7 Loading, Empty, and Error States 

### Loading State 

While search is executing, show skeleton result cards:

```ts
// 4 skeleton cards
{
  marginBottom: 28,
}
```

Each skeleton card:

- Title bar: 260px × 16px rectangle (`surfaceAlt` bg, borderRadius 4, animation: pulse 1.5s ease-in-out infinite)  
- URL bar: 160px × 10px rectangle, marginTop 6px  
- Snippet lines: 2 bars (100% × 12px, then 75% × 12px), marginTop 10px, gap 6px  
- Action buttons: 3 bars (80px × 22px each), marginTop 12px, gap 8px

The `keepPreviousData` flag in React Query means old results remain visible with the skeleton overlay when refining a query. The skeleton only shows for the initial load (when no previous results exist).

### No Results State 

When the search completes but returns zero results for the active filter:

```ts
{
  padding: "48px 20px",
  textAlign: "center",
}
```

- Search icon (28px, `textFaint`, marginBottom 12px)  
- "No results for "{query}"" (14px, weight 400, `text`). The query is shown in weight 300 with quotes.  
- Filter-specific suggestion (12px, weight 300, `textMuted`, marginTop 8px, maxWidth 360px, margin auto):  
  - Filter "all": "Try different keywords or check your spelling."  
  - Filter "web": "No web results found. Try broader search terms."  
  - Filter "chat": "No matching conversations. Try searching within a specific Instance."  
  - Filter "file": "No matching files. Check the file name or tags."  
  - Filter "memory": "No matching memories. Memories are created from your conversations over time."  
  - Filter "internal": "No matching workspaces, personas, or teams."

### Error State 

If the search Edge Function returns an error:

```ts
{
  padding: "32px 20px",
  textAlign: "center",
}
```

- AlertCircle icon (24px, `textFaint`, marginBottom 8px)  
- "Search failed" (14px, weight 400, `text`)  
- "Something went wrong. Please try again." (12px, weight 300, `textMuted`, marginTop 4px)  
- "Retry" button (bordered, 11px, padding 5px 14px, borderRadius 6). Calls `refetch()`.

### Partial Backend Failure 

If some backends succeed but others fail (common with external APIs), the results display normally but a small warning banner appears above the results:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 6,
  padding: "6px 10px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  marginBottom: 16,
  fontSize: 11,
  color: theme.textMuted,
  fontWeight: 350,
}
```

- AlertTriangle icon (11px, `textFaint`)  
- Text: "Some search sources didn't respond. Results may be incomplete."

This banner appears when any backend in `Promise.allSettled` returns `status: "rejected"`. It is dismissible (X icon, 10px).

---

## 6.8 Result Action Flows 

### "Chat with this" 

Creates a new chat pre-loaded with the search result as context:

```ts
async function chatWithResult(result: SearchResult) {
  const supabase = createClient();

  // 1. Create a new chat
  const { data: chat } = await supabase
    .from("chats")
    .insert({
      user_id: userId,
      title: `Discussion: ${result.title}`,
      instance_id: result.metadata.instance_id || null,
    })
    .select()
    .single();

  // 2. Create a system message with the search result as context
  await supabase.from("messages").insert({
    chat_id: chat.id,
    sender_type: "system",
    content: `[Search context] The user found this during search and wants to discuss it:\n\nTitle: ${result.title}\nSource: ${result.url}\n\n${result.snippet}`,
    metadata: {
      search_result: true,
      source_type: result.type,
      source_url: result.metadata.full_url || null,
    },
  });

  // 3. Navigate to the new chat
  navigate(`/chat?id=${chat.id}`);
}
```

The system message provides the persona with the search context. The persona can then discuss, analyze, or act on the content.

### "Send to Instance" 

Opens a popover to select a destination Instance:

```ts
// Popover positioned below the button
{
  position: "absolute",
  top: "100%",
  left: 0,
  marginTop: 4,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  padding: 6,
  minWidth: 220,
  maxHeight: 260,
  overflowY: "auto",
  boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
  zIndex: 20,
}
```

**Popover header:** "Send to Instance" (11px, weight 500, `textMuted`, uppercase, padding 6px 10px)

**Instance list:** Each Instance as a row:

```ts
{
  padding: "7px 10px",
  borderRadius: 6,
  cursor: "pointer",
  display: "flex",
  alignItems: "center",
  gap: 8,
  fontSize: 12,
  color: theme.text,
  fontWeight: 400,
}
// Hover: background theme.surfaceAlt
```

- Instance icon (emoji or LayoutGrid fallback, 14px)  
- Instance name (12px, weight 400\)  
- Type badge (10px, `textFaint`)

Clicking an Instance:

```ts
async function sendToInstance(result: SearchResult, instanceId: string) {
  const supabase = createClient();

  // Create a workspace_items entry in the selected Instance
  await supabase.from("workspace_items").insert({
    instance_id: instanceId,
    type: result.type === "file" ? "file_reference" : "note",
    title: result.title,
    content: result.snippet,
    source_url: result.metadata.full_url || result.url,
    metadata: {
      search_source: result.type,
      original_result_id: result.id,
    },
  });

  // Show toast notification
  showToast(`Saved to ${instanceName}`);
}
```

### "Save" (Bookmark) 

Toggles a bookmark on the search result:

```ts
async function toggleSaveResult(result: SearchResult) {
  const supabase = createClient();

  const { data: existing } = await supabase
    .from("saved_results")
    .select("id")
    .eq("user_id", userId)
    .eq("source_id", result.id)
    .maybeSingle();

  if (existing) {
    // Un-save
    await supabase.from("saved_results").delete().eq("id", existing.id);
  } else {
    // Save
    await supabase.from("saved_results").insert({
      user_id: userId,
      source_id: result.id,
      type: result.type,
      title: result.title,
      url: result.url,
      snippet: result.snippet,
      metadata: result.metadata,
    });
  }
}
```

---

## 6.9 Search History & Saved Searches 

When the search input is empty, the results area shows two sections: Recent Searches and Saved Searches.

### Recent Searches Section 

**Header:**

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  marginBottom: 12,
}
```

- "RECENT SEARCHES" (11px, weight 500, `textMuted`, uppercase, letterSpacing 0.1em)  
- "Clear all" link (10px, weight 350, `textFaint`, cursor pointer). Deletes all non-saved search history entries for this user.

**Each history item:**

```ts
{
  padding: "10px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  alignItems: "center",
  gap: 10,
  cursor: "pointer",
}
```

- History icon (13px, `textFaint`, flexShrink 0\)  
- Query text: 13px, weight 350, `text`, flex 1, overflow hidden, textOverflow ellipsis, whiteSpace nowrap  
- Filter badge (only if filter was not "all"): chip showing the filter name (9px, `textFaint`, `surfaceAlt` bg, borderRadius 4, padding 1px 5px)  
- Timestamp: 10px, weight 300, `textFaint`  
- Bookmark toggle button: Bookmark icon (12px). Filled with `accent` if saved, outline in `textFaint` if not. Background none, border none, cursor pointer, padding 2px. Click (with stopPropagation) toggles the `saved` flag.

Clicking a history item: populates the search input with the query, sets the filter to the history entry's filter, and triggers a search.

### Saved Searches Section 

**Header:**

```ts
{
  marginTop: 28,
  marginBottom: 12,
}
// "SAVED SEARCHES" (11px, weight 500, textMuted, uppercase, letterSpacing 0.1em)
```

**Each saved item:**

```ts
{
  padding: "10px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  alignItems: "center",
  gap: 10,
  cursor: "pointer",
}
```

- Bookmark icon (13px, `accent`, filled)  
- Query text: 13px, weight 350, `text`, flex 1  
- Remove button: X icon (11px, `textFaint`, background none, border none, cursor pointer, padding 2px). Removes the saved flag (un-bookmarks the search).

Clicking populates the search input and triggers a search, same as recent searches.

### Empty History State 

If the user has no search history at all:

```ts
{
  padding: "40px 20px",
  textAlign: "center",
}
```

- Search icon (24px, `textFaint`, marginBottom 8px)  
- "Search across your entire workspace" (14px, weight 400, `text`)  
- "Find conversations, files, memories, and web content — all in one place." (12px, weight 300, `textMuted`, marginTop 6px, maxWidth 360px, margin auto)

---

## 6.10 Database Schema 

### search\_history Table 

```sql
CREATE TABLE public.search_history (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  query TEXT NOT NULL,
  filter TEXT NOT NULL DEFAULT 'all',
  result_count INTEGER DEFAULT 0,
  saved BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.search_history ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own search history"
  ON public.search_history FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_search_history_user ON public.search_history(user_id, created_at DESC);
CREATE INDEX idx_search_history_saved ON public.search_history(user_id)
  WHERE saved = true;
```

**History cap enforcement (RPC):**

```sql
CREATE OR REPLACE FUNCTION trim_search_history(p_user_id UUID, p_max INT DEFAULT 50)
RETURNS void LANGUAGE sql AS $$
  DELETE FROM search_history
  WHERE id IN (
    SELECT id FROM search_history
    WHERE user_id = p_user_id
      AND saved = false
    ORDER BY created_at DESC
    OFFSET p_max
  );
$$;
```

Non-saved entries are capped at 50\. Saved entries are never auto-deleted.

### saved\_results Table 

```sql
CREATE TABLE public.saved_results (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  source_id TEXT NOT NULL,           -- The result.id from search (e.g., "web-0-https://...")
  type TEXT NOT NULL,                -- "web" | "chat" | "file" | "memory" | "internal"
  title TEXT NOT NULL,
  url TEXT NOT NULL,
  snippet TEXT DEFAULT '',
  metadata JSONB DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.saved_results ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own saved results"
  ON public.saved_results FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_saved_results_user ON public.saved_results(user_id, created_at DESC);
CREATE UNIQUE INDEX idx_saved_results_unique ON public.saved_results(user_id, source_id);
```

The unique index on `(user_id, source_id)` prevents duplicate bookmarks.

---

## 6.11 Tier Gating 

| Feature | Free | Plus | Premium | Pro |
| :---- | :---- | :---- | :---- | :---- |
| Chat search | ✓ | ✓ | ✓ | ✓ |
| File search | ✓ | ✓ | ✓ | ✓ |
| Internal search | ✓ | ✓ | ✓ | ✓ |
| Web search | — | ✓ | ✓ | ✓ |
| Memory search (semantic) | — | — | ✓ | ✓ |
| Search history retention | 10 entries | 50 entries | 50 entries | 50 entries |
| Saved searches limit | 5 | 25 | 50 | Unlimited |
| Saved results limit | 10 | 50 | 200 | Unlimited |

Free-tier users can search chats, files, and internal entities. Web search requires Plus. Memory search (which requires embedding generation, an API cost) requires Premium.

When a gated filter is clicked by a user below the required tier, the filter chip briefly shakes and a tooltip appears: "Web search requires Plus" or "Memory search requires Premium." The results area shows:

```ts
{
  padding: "32px 20px",
  textAlign: "center",
}
```

- Lock icon (20px, `textFaint`, marginBottom 8px)  
- "{Feature} requires {tier}" (13px, weight 400, `text`)  
- "Upgrade to unlock search across {description}." (12px, weight 300, `textMuted`, marginTop 4px)  
- "Upgrade" button (accent bg, accentText, borderRadius 6, padding 6px 16px, fontSize 11). Opens pricing modal.

---

## 6.12 Performance Considerations 

### Debouncing 

The 300ms debounce on the search input prevents excessive API calls. Combined with React Query's `keepPreviousData`, the user sees a smooth transition between results as they type.

### Backend Timeout 

Each search backend has a 5-second timeout. If a backend doesn't respond within 5 seconds, its `Promise.allSettled` entry resolves as rejected and the partial backend failure banner appears. This ensures the search screen remains responsive even if one backend is slow.

```ts
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  return Promise.race([
    promise,
    new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error("Timeout")), ms)
    ),
  ]);
}

// Usage in the Edge Function:
const backendResults = await Promise.allSettled(
  backends.map(backend => withTimeout(
    executeBackend(backend, query, userId, instanceScope, limit),
    5000
  ))
);
```

### Caching 

React Query caches search results for 60 seconds (`staleTime: 60_000`). If the user switches between filter chips, cached results for the same query are displayed instantly without re-querying. The `queryKey` includes the query, filter, and scope, so different filter selections produce separate cache entries.

### Full-Text Search Index Maintenance 

The generated `tsvector` columns on `chats`, `messages`, and `files` are automatically maintained by PostgreSQL (they use `GENERATED ALWAYS AS ... STORED`). No manual index rebuilds are required. The GIN indexes on these columns provide fast lookup.

### Embedding Generation Cost 

Memory search requires generating a query embedding for every search. The `text-embedding-3-small` model from OpenAI is efficient (\~$0.02 per 1M tokens). For typical search queries (5–20 tokens), the cost is negligible. However, this is gated to Premium+ tiers to manage cost at scale.

---

*End of Part 6\. Proceed to Part 7: Spaces / Instances Screen.*

# PART 7: SPACES / INSTANCES SCREEN 

---

Instances are the organizational backbone of aiConnected. Each Instance is a complete workspace scoped to a project, client, topic, or personal domain. Instances contain chats, files, tasks, personas, and settings — all of which can be configured independently through the settings cascade.

---

## 7.1 Data Model 

### Instances Table 

```sql
CREATE TABLE public.instances (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  type TEXT NOT NULL DEFAULT 'project' CHECK (type IN ('project', 'client', 'personal', 'research', 'custom')),
  description TEXT DEFAULT '',
  icon TEXT DEFAULT 'folder',         -- Lucide icon name
  color TEXT DEFAULT '#a3a3a3',       -- Accent color for the instance
  settings JSONB DEFAULT '{}'::jsonb, -- Instance-level setting overrides
  archived_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.instances ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own instances"
  ON public.instances FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_instances_user ON public.instances(user_id, updated_at DESC);

CREATE TRIGGER set_instances_updated_at
  BEFORE UPDATE ON public.instances FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### Instance Personas (Join Table) 

```sql
CREATE TABLE public.instance_personas (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  instance_id UUID NOT NULL REFERENCES public.instances(id) ON DELETE CASCADE,
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  role_in_instance TEXT DEFAULT '',   -- "Lead Designer", "Copy Reviewer", etc.
  deployed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  removed_at TIMESTAMPTZ,
  UNIQUE(instance_id, persona_id) WHERE (removed_at IS NULL)
);

ALTER TABLE public.instance_personas ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage personas in own instances"
  ON public.instance_personas FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.instances
      WHERE instances.id = instance_personas.instance_id
      AND instances.user_id = auth.uid()
    )
  );
```

### Tasks Table 

```sql
CREATE TABLE public.tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  instance_id UUID NOT NULL REFERENCES public.instances(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  description TEXT DEFAULT '',
  status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'in_progress', 'review', 'done')),
  assignee_persona_id UUID REFERENCES public.personas(id) ON DELETE SET NULL,
  priority TEXT DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
  due_date DATE,
  position INTEGER NOT NULL DEFAULT 0,   -- For ordering within status column
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at TIMESTAMPTZ
);

ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage tasks in own instances"
  ON public.tasks FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.instances
      WHERE instances.id = tasks.instance_id
      AND instances.user_id = auth.uid()
    )
  );

CREATE INDEX idx_tasks_instance_status ON public.tasks(instance_id, status);
```

### Workspace Items Table 

```sql
CREATE TABLE public.workspace_items (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  instance_id UUID NOT NULL REFERENCES public.instances(id) ON DELETE CASCADE,
  section TEXT NOT NULL DEFAULT 'General',
  title TEXT NOT NULL,
  content TEXT DEFAULT '',
  type TEXT NOT NULL CHECK (type IN ('decision', 'fact', 'note', 'spec', 'link', 'file_ref')),
  source_chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  source_message_id UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  position INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.workspace_items ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage workspace in own instances"
  ON public.workspace_items FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.instances
      WHERE instances.id = workspace_items.instance_id
      AND instances.user_id = auth.uid()
    )
  );
```

### Instance Settings JSON Structure 

```ts
interface InstanceSettings {
  voice?: string;               // Voice/tone for AI responses
  tone?: string;
  personality?: string;
  model_preferences?: {
    research?: string;          // OpenRouter model ID
    writing?: string;
    coding?: string;
    design?: string;
    planning?: string;
    reasoning?: string;
  };
  storage?: "local" | "external" | "hybrid";
  external_storage_config?: {
    provider: "google_drive" | "dropbox" | "s3";
    connected: boolean;
  };
  cleanup?: {
    frequency: "daily" | "weekly" | "monthly" | "never";
    auto_rename: boolean;
    suggested_moves: boolean;
  };
  file_visibility_default?: "visible" | "instance" | "hidden";
  default_persona_ids?: string[];
}
```

### Settings Cascade 

Instance settings inherit from a four-level cascade:

```
Platform Defaults → Global Instance Settings → Type Template → Instance Overrides
```

Each level can override the previous. The resolved settings for an Instance are computed by merging these layers left-to-right, with later layers winning.

```ts
function resolveInstanceSettings(
  platformDefaults: InstanceSettings,
  globalSettings: InstanceSettings,
  typeTemplate: InstanceSettings,
  instanceOverrides: InstanceSettings
): InstanceSettings {
  return {
    ...platformDefaults,
    ...globalSettings,
    ...typeTemplate,
    ...instanceOverrides,
  };
}
```

---

## 7.2 Instance List View 

The list view shows all of the user's Instances in a scrollable page.

### Header 

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "baseline",
  marginBottom: 40,
}
```

- Title: "Spaces" (28px, weight 300, letter-spacing \-0.03em)  
- "+New" button: Plus icon (14px) \+ "New" text. `border: 1px solid theme.border, borderRadius: 8, padding: 7px 16px, fontSize: 13, color: textSec, fontWeight: 400`.

### Instance Items 

Each Instance is a clickable row:

```ts
{
  padding: "20px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  cursor: "pointer",
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
}
```

**Left side:**

- Name: 16px, weight 450, `text`  
- Metadata row (marginTop 4px): type badge \+ chat count \+ file count \+ persona avatars (all 13px, weight 300, `textMuted`, gap 12px)  
- Persona avatars: 18px Avatar circles for each assigned persona

**Right side:**

- Status dot (StatusDot component)  
- ChevronRight (16px, `textFaint`)

Clicking navigates to the Instance Detail view: `go("spaces", { inst: instance.id, tab: "Overview" })`.

### Data Query 

```ts
const { data: instances } = useQuery({
  queryKey: ["instances"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("instances")
      .select(`
        *,
        personas:instance_personas(
          persona:personas(id, name, status)
        ),
        chats:chats(count),
        files:files(count)
      `)
      .is("archived_at", null)
      .order("updated_at", { ascending: false });
    return data ?? [];
  },
});
```

### Empty State 

When user has no Instances:

```
No spaces yet. Create your first space to organize your conversations and files.
```

(13px, weight 300, `textFaint`, centered, with a "Create Space" button below.)

---

## 7.3 Instance Detail View 

### Container 

```ts
{
  display: "flex",
  flexDirection: "column",
  height: "100%",
  maxWidth: 900,
  margin: "0 auto",
  width: "100%",
}
```

### Header 

```ts
{ padding: `${py}px ${px}px 0`, flexShrink: 0 }
```

**Title row:** (flex, justify-between)

- Instance name: 24px, weight 300, letter-spacing \-0.02em  
- Overflow menu button: MoreHorizontal icon (16px), bordered (1px solid border, borderRadius 8, padding 6px 8px)

**Subtitle:** `{type} · {chats} chats · {files} files` (13px, weight 300, `textMuted`, marginTop 4px)

**Overflow menu dropdown:** (positioned absolute, right-aligned, 160px min-width, borderRadius 10, boxShadow)

| Icon | Label | Notes |
| :---- | :---- | :---- |
| Edit3 (12px) | Rename | Opens inline rename |
| Layers (12px) | Change Type | Opens type picker |
| Download (12px) | Export | Exports entire Instance |
| Archive (12px) | Archive | Moves to archived state |
| Trash2 (12px) | Delete | Destructive, colored \#ef4444 |

Each menu item: 12px, weight 400, padding 7px 10px, borderRadius 6\.

### Tab Navigation 

6 tabs in a horizontal row below the header:

```ts
{
  display: "flex",
  gap: mobile ? 16 : 24,
  marginTop: 28,
  borderBottom: `1px solid ${theme.borderSubtle}`,
  overflowX: "auto",
}
```

Each tab:

```ts
{
  padding: "0 0 12px",
  fontSize: 13,
  fontWeight: isActive ? 500 : 350,
  color: isActive ? theme.text : theme.textMuted,
  background: "none",
  border: "none",
  borderBottom: isActive ? `1.5px solid ${theme.accent}` : "1.5px solid transparent",
  cursor: "pointer",
  whiteSpace: "nowrap",
  flexShrink: 0,
}
```

Tabs: **Overview**, **Tasks**, **Workspace**, **Chats**, **Files**, **Settings**

### Tab Content Container 

```ts
{
  flex: 1,
  overflowY: "auto",
  padding: `24px ${px}px ${py}px`,
}
```

---

## 7.4 Overview Tab 

The Overview tab is the Instance's landing page. It shows 3 major sections: Open Forum, Persona Roster, and Activity.

### Open Forum 

A persistent, shared chat that lives within the Instance. All assigned personas participate. This is like a group channel — users can post messages and all personas respond.

**Header row:**

```ts
{
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  marginBottom: 12,
}
```

- Hash icon (14px, `textMuted`) \+ "Open Forum" (14px, weight 450\) \+ "Persistent" badge (10px, weight 300, `surfaceAlt` bg, borderRadius 8\)  
- "Expand" button: ExternalLink icon (9px) \+ "Expand" text, bordered, navigates to the forum's full chat view

**Chat container:** Bordered card with borderRadius 12, overflow hidden.

**Message area:** maxHeight 240px, overflowY auto, padding 12px 14px.

Each forum message:

```ts
{
  marginBottom: 14,
  display: "flex",
  gap: 8,
}
```

- 22px avatar  
- Content: sender name (11px, weight 500\) \+ timestamp (9px, weight 300, `textFaint`) \+ message text (12px, weight 350, `textSec`, line-height 1.5)

**Input row:** At the bottom, `surfaceAlt` background, border-top.

```ts
{
  borderTop: `1px solid ${theme.borderSubtle}`,
  padding: "8px 12px",
  display: "flex",
  alignItems: "center",
  gap: 8,
  background: theme.surfaceAlt,
}
```

- Input: "Message the forum..." placeholder (12px, weight 350\)  
- Paperclip icon (13px, `textFaint`)  
- ArrowUp icon (13px, `textMuted`)

The forum chat is stored as a regular chat with a special flag: `chats.metadata.is_forum = true`. It is auto-created when the Instance is created and cannot be deleted independently.

### Persona Roster 

Shown when the Instance has assigned personas.

Header: "PERSONAS" (11px, weight 500, `textMuted`, uppercase, letter-spacing 0.1em).

Personas displayed as clickable chips in a wrapping flex row (gap 10px):

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  padding: "6px 12px 6px 6px",
  background: theme.surfaceAlt,
  borderRadius: 10,
  cursor: "pointer",
}
```

- 22px avatar \+ name (12px, weight 400\) \+ StatusDot (5px)  
- Clicking navigates to the persona's detail page

"+ Add" button: dashed border, UserPlus icon (11px), opens a persona picker.

### Activity 

Header: "ACTIVITY" (same section header style).

Activity items (specific to this Instance, from `activity_log` where `entity_type = 'instance' AND entity_id = instance.id`):

```ts
{
  padding: "12px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  justifyContent: "space-between",
}
```

- Action text: 14px, weight 350, `text`  
- Timestamp: 11px, weight 300, `textFaint`

---

## 7.5 Tasks Tab 

### Header Row 

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  marginBottom: 20,
}
```

- Task count: `{n} tasks` (14px, weight 400\)  
- "+New Task" button: Plus icon (12px) \+ "New Task" text, bordered

### Task List (v1) 

In v1, tasks are displayed as a flat list sorted by status. Each task:

```ts
{
  padding: "14px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
}
```

- Title: 14px, weight 400, `text`  
- Metadata: `{assignee_persona_name} · {due_date}` (12px, weight 300, `textMuted`)  
- Status dot: active (green) for in\_progress/todo, idle (amber) for review, sleeping (gray) for done

### Task Board (v2 — Kanban) 

Four columns: To Do, In Progress, Review, Done. Each column header shows the count.

Task cards within columns are draggable (using @dnd-kit/core):

```ts
// Task card
{
  padding: "12px 14px",
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 8,
  marginBottom: 8,
  cursor: "grab",
}
```

- Title: 13px, weight 450  
- Assignee: 16px avatar \+ name (11px, weight 350, `textMuted`)  
- Priority indicator: colored dot (urgent=red, high=amber, medium=transparent, low=transparent)  
- Due date: 10px, weight 300, `textFaint`

### Quick Add 

Below the task list, an inline "Add task" input:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  padding: "12px 0",
  borderBottom: `1px dashed ${theme.border}`,
}
```

- Plus icon (12px, `textFaint`)  
- Input: "Add a task..." placeholder (13px, weight 350\)  
- Press Enter to create task with default status "todo"

---

## 7.6 Workspace Tab 

The Workspace is a curated collection of important content promoted from chats. It serves as the Instance's knowledge base.

### Filter Row 

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  marginBottom: 20,
  flexWrap: "wrap",
  gap: 10,
}
```

**Section filter chips:** "All" \+ one chip per unique section name from workspace items. Same pill chip styling as other screens.

**Import button:** FilePlus icon (12px) \+ "Import from Chat" text, bordered. Opens a picker where the user can select messages from chats within this Instance to promote to the workspace.

### Workspace Grid 

```ts
{
  display: "grid",
  gridTemplateColumns: mobile ? "1fr" : "1fr 1fr",
  gap: 16,
}
```

Each workspace item card:

```ts
{
  padding: 16,
  background: theme.surfaceAlt,
  borderRadius: 10,
  cursor: "pointer",
}
```

- Header row: title (13px, weight 450\) \+ MoreHorizontal icon (14px, `textFaint`)  
- Content: 12px, weight 350, `textSec`, line-height 1.5, marginBottom 10  
- Footer: section badge (10px, `bg` background, borderRadius 10, `textMuted`, padding 2px 8px) \+ type label (10px, `textFaint`) \+ source label (10px, `textFaint`, "from {chat\_title}")

### Drag-and-Drop Canvas (v2) 

Below the grid, a placeholder for the future canvas view:

```ts
{
  marginTop: 24,
  padding: 20,
  border: `1px dashed ${theme.border}`,
  borderRadius: 10,
  textAlign: "center",
}
```

- "Drag-and-drop canvas view coming soon" (12px, weight 350, `textMuted`)  
- "Miro-style whiteboard for connecting components" (11px, weight 300, `textFaint`)

---

## 7.7 Chats Tab 

Lists all chats belonging to this Instance.

### Header Row 

- Chat count: `{n} conversations` (14px, weight 400\)  
- "+New Chat" button: Plus icon (12px) \+ "New Chat", bordered. Creates a new chat pre-assigned to this Instance.

### Chat List 

Each chat item:

```ts
{
  padding: "14px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  cursor: "pointer",
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
}
```

- Title: 14px, weight 400  
- Metadata: timestamp \+ persona avatars (14px circles) — 12px, weight 300, `textMuted`  
- ChevronRight (14px, `textFaint`)

Clicking navigates to the chat: `go("chat", { chat: c.id })`.

**Data query:** `SELECT * FROM chats WHERE instance_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC`.

---

## 7.8 Files Tab 

Scoped file browser for this Instance. Reuses the same file list pattern as the Global Files screen but filtered to `instance_id`.

### Filter Row 

- Filter chips: All, Uploads, AI Generated (same styling as global files)  
- Upload button: Upload icon (12px) \+ "Upload" text, bordered

### File List 

Same file item layout as Global Files (Section 8.2) but without the Instance name column (since we're already scoped).

Each file row:

- File type icon (14px, `textFaint`)  
- Name: 13px, weight 400, ellipsis  
- Metadata: size \+ date \+ origin badge ("AI" or "Upload") \+ source chat name (if linked)  
- Actions: visibility toggle, download, delete (icon buttons)

### Empty State 

```
No files yet. Upload files or they'll appear here when AI generates them.
```

(12px, weight 300, `textFaint`, centered, padding 48px)

---

## 7.9 Settings Tab 

The Settings tab shows the resolved configuration for this Instance, with clear indicators of which settings are inherited vs overridden.

### Header 

- "Instance Settings" (14px, weight 450\)  
- Description: "Settings cascade: Global → Type → Instance. Custom overrides shown below." (12px, weight 300, `textMuted`)

### Settings List 

Each setting row:

```ts
{
  padding: "16px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
}
```

**Left side:**

- Setting name: 13px, weight 450, `text`, capitalized  
- Current value: 12px, weight 350, `textSec`, marginTop 3px  
- Source indicator: 10px, weight 300, with icon  
  - If custom override: Edit3 icon (9px) \+ "Custom override" in `accent` color  
  - If inherited: ArrowRight icon (9px) \+ `Inherited from {source}` in `textFaint`

**Right side:**

- "Edit" button: bordered, 11px  
- "Reset" button (visible only when custom): bordered, 11px, `textFaint`. Removes the instance override, reverting to inherited value.

### Settings Available for Override 

| Setting Key | Type | Description |
| :---- | :---- | :---- |
| `voice` | text | AI voice/tone for responses |
| `tone` | text | Communication style |
| `personality` | text | Behavioral characteristics |
| `model` | model\_preferences | Model assignments by role |
| `storage` | enum | Storage strategy |
| `cleanup` | object | Auto-rename, cleanup frequency, suggested moves |

### Edit Flow 

Clicking "Edit" on a setting opens an inline editor or modal depending on the setting type:

- **Text settings** (voice, tone, personality): Inline text area, save/cancel buttons  
- **Model preferences**: Opens a model role assignment grid (same as Settings screen model roles)  
- **Storage**: Dropdown selector with external storage connection flow  
- **Cleanup**: Toggle switches \+ frequency dropdown

On save, the override is written to `instances.settings` JSONB, and the source indicator updates to "Custom override."

---

## 7.10 Create Instance Flow 

Triggered by the "+New" button on the Instance List or via the Command Palette.

### Modal Layout 

Centered modal, 440px wide (90% on mobile), borderRadius 16px, padding 28px.

### Step 1: Name & Type 

- "Create a new space" heading (16px, weight 450\)  
- Name input: "Space name" placeholder (14px)  
- Type selector: 5 radio options in a vertical list  
  - Project, Client, Personal, Research, Custom  
  - Each with icon \+ name \+ brief description  
  - Selected option highlighted with `surfaceAlt` background and accent border

### Step 2: Optional Config 

- Description textarea: "What is this space for?" (optional)  
- Assign personas: Multi-select persona picker from existing personas  
- Apply type template: Checkbox to apply the type template's default settings

### Action Buttons 

- "Create" (accent bg, accentText, borderRadius 6, full-width at bottom)  
- "Cancel" link (textMuted, centered below)

On creation: INSERT into `instances` table, optionally insert `instance_personas` entries, create the forum chat, insert activity log entry.

---

*End of Part 7\. Proceed to Part 8: Global Files Screen.*

# PART 8: GLOBAL FILES SCREEN 

---

The Global Files screen provides a unified view of every file across all Instances. Files can originate from user uploads, AI-generated outputs, or browser extracts. The screen supports search, filtering by origin, bulk selection with batch operations, file preview, drag-and-drop upload, and per-file actions.

This screen shows files at the global level. Each Instance also has its own Files tab (Part 7, Section 7.3.5) which is scoped to that Instance. The data model, upload flow, preview modal, and AI-generated file pipeline are shared between both views — only the query scope differs.

---

## 8.1 Data Model 

### Files Table 

```sql
CREATE TABLE public.files (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  instance_id UUID REFERENCES public.instances(id) ON DELETE SET NULL,
  chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  message_id UUID REFERENCES public.messages(id) ON DELETE SET NULL,  -- Source message for AI-generated files
  name TEXT NOT NULL,
  type TEXT NOT NULL CHECK (type IN ('pdf', 'image', 'doc', 'code', 'audio', 'video', 'spreadsheet', 'other')),
  mime_type TEXT NOT NULL,
  size_bytes BIGINT NOT NULL DEFAULT 0,
  storage_path TEXT NOT NULL,        -- Path in Supabase Storage: user-files/{user_id}/{file_id}/{name}
  origin TEXT NOT NULL DEFAULT 'uploaded' CHECK (origin IN ('uploaded', 'ai_generated', 'browser_extract')),
  visibility TEXT NOT NULL DEFAULT 'visible' CHECK (visibility IN ('visible', 'instance', 'hidden')),
  tags TEXT[] DEFAULT '{}',
  metadata JSONB DEFAULT '{}'::jsonb,
  deleted_at TIMESTAMPTZ,            -- Soft delete
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.files ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own files"
  ON public.files FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_files_user ON public.files(user_id, created_at DESC);
CREATE INDEX idx_files_instance ON public.files(instance_id) WHERE instance_id IS NOT NULL;
CREATE INDEX idx_files_active ON public.files(user_id, created_at DESC) WHERE deleted_at IS NULL;

-- Full-text search index (used by Part 6 Search as well)
ALTER TABLE public.files ADD COLUMN IF NOT EXISTS search_tsv tsvector
  GENERATED ALWAYS AS (
    to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(tags::text, ''))
  ) STORED;
CREATE INDEX idx_files_tsv ON public.files USING GIN (search_tsv);
```

### metadata JSONB Structure 

The `metadata` field stores type-specific information about the file:

```ts
interface FileMetadata {
  // Common
  ai_summary?: string;            // AI-generated description of the file contents

  // Images
  width?: number;
  height?: number;
  thumbnail_path?: string;        // Smaller version in storage for grid preview

  // PDFs/Docs
  page_count?: number;
  word_count?: number;
  ocr_text?: string;              // Extracted text for searchability

  // Code
  language?: string;              // "typescript", "python", etc.
  line_count?: number;

  // AI-generated file provenance
  generating_persona_id?: string;
  generating_persona_name?: string;
  generation_prompt?: string;     // Truncated prompt that triggered file creation

  // Browser extracts
  source_url?: string;
  extract_type?: string;
}
```

### Storage Configuration 

**Supabase Storage bucket:** `user-files`

```sql
INSERT INTO storage.buckets (id, name, public, file_size_limit)
VALUES ('user-files', 'user-files', false, 262144000); -- 250 MB max per file (Pro tier)

-- RLS: users can only access their own folder
CREATE POLICY "Users manage own files in storage"
  ON storage.objects FOR ALL
  USING (
    bucket_id = 'user-files'
    AND (storage.foldername(name))[1] = auth.uid()::text
  );
```

**File path structure:** `user-files/{user_id}/{file_id}/{original_filename}`

The `file_id` intermediate folder prevents name collisions across uploads and provides clean per-file organization. Each file gets its own UUID folder.

### Visibility Levels 

| Level | Description | Global Files Screen | Instance Files Tab | Chat Reference | Search |
| :---- | :---- | :---- | :---- | :---- | :---- |
| `visible` | Fully visible | ✓ Shown | ✓ Shown | ✓ Can be referenced | ✓ Searchable |
| `instance` | Instance-scoped | ✗ Hidden | ✓ Shown | ✓ Within instance chats | ✓ Searchable (scoped) |
| `hidden` | Hidden | ✗ Hidden | ✗ Hidden | ✓ Via direct link only | ✗ Not searchable |

The Global Files screen shows files where `visibility = 'visible'`. Instance Files tabs show files where `instance_id = {instance}` AND `visibility IN ('visible', 'instance')`.

### TypeScript Interface 

```ts
interface FileRecord {
  id: string;
  user_id: string;
  instance_id: string | null;
  chat_id: string | null;
  message_id: string | null;
  name: string;
  type: "pdf" | "image" | "doc" | "code" | "audio" | "video" | "spreadsheet" | "other";
  mime_type: string;
  size_bytes: number;
  storage_path: string;
  origin: "uploaded" | "ai_generated" | "browser_extract";
  visibility: "visible" | "instance" | "hidden";
  tags: string[];
  metadata: FileMetadata;
  deleted_at: string | null;
  created_at: string;
  // Joined
  instance_name?: string;
  download_url?: string;          // Signed URL for direct download
}
```

---

## 8.2 Screen Layout 

### Container 

```ts
{
  padding: `${py}px ${px}px`,
  overflowY: "auto",
  height: "100%",
}

// Inner wrapper
{
  maxWidth: 900,
  margin: "0 auto",
}
```

### Content Flow (Top to Bottom) 

1. Header row (title \+ action buttons)  
2. Search bar  
3. Batch actions bar (conditional: select mode \+ items selected)  
4. Filter chips \+ file count  
5. File list (or empty/loading state)

---

## 8.3 Header 

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "baseline",
  marginBottom: 20,
}
```

**Left side:**

- Title: "Files" (28px, weight 300, `text`, letterSpacing \-0.03em)

**Right side** (display flex, gap 6px):

**Select button:**

```ts
// Default state
{
  display: "flex",
  alignItems: "center",
  gap: 4,
  background: "none",
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  padding: "7px 14px",
  fontSize: 12,
  color: theme.textMuted,
  cursor: "pointer",
  fontWeight: 400,
}

// Active state (select mode on)
{
  background: theme.accent,
  border: "none",
  color: theme.accentText,
}
```

- Square icon (12px) \+ text: "Select" (default) or `{n} selected` (when items are selected)  
- Click toggles `fileSelectMode`. When toggling off, clears `fileSelected`.

**Upload button:**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 4,
  background: "none",
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  padding: "7px 16px",
  fontSize: 13,
  color: theme.textSec,
  cursor: "pointer",
  fontWeight: 400,
}
```

- Upload icon (14px) \+ "Upload"  
- Click opens the native file picker (`<input type="file" multiple>` triggered via ref)

---

## 8.4 Search Bar 

```ts
{
  display: "flex",
  alignItems: "center",
  background: theme.surfaceAlt,
  borderRadius: 10,
  padding: "8px 12px",
  marginBottom: 16,
}
```

- Search icon (13px, `textFaint`, marginRight 8px, flexShrink 0\)  
- Input: "Search files..." placeholder (12px, weight 350, `text`, border none, background none, outline none, width 100%)  
- Clear button (X icon, 12px, `textFaint`, cursor pointer). Visible only when input has text.

**Search behavior:** Client-side filtering on the loaded file list. Matches against file name (case-insensitive substring match):

```ts
const [fileSearchQuery, setFileSearchQuery] = useState("");

const filteredFiles = useMemo(() => {
  let files = allFiles;

  // 1. Apply origin filter
  if (fileFilter !== "all") {
    files = files.filter(f => f.origin === fileFilter);
  }

  // 2. Apply search query
  if (fileSearchQuery) {
    const q = fileSearchQuery.toLowerCase();
    files = files.filter(f =>
      f.name.toLowerCase().includes(q) ||
      (f.instance_name || "").toLowerCase().includes(q) ||
      (f.tags || []).some(tag => tag.toLowerCase().includes(q))
    );
  }

  return files;
}, [allFiles, fileFilter, fileSearchQuery]);
```

Search matches against file name, parent instance name, and tags.

---

## 8.5 Batch Actions Bar 

Visible when `fileSelectMode === true && fileSelected.length > 0`:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  marginBottom: 12,
  padding: "8px 12px",
  background: theme.surfaceAlt,
  borderRadius: 8,
}
```

**Selected count:**

```ts
{
  fontSize: 11,
  color: theme.text,
  fontWeight: 450,
  marginRight: 4,
}
// Text: "{n} selected"
```

**Action buttons** (shared style):

```ts
{
  background: "none",
  border: `1px solid ${theme.border}`,
  borderRadius: 6,
  padding: "3px 10px",
  fontSize: 10,
  color: theme.textMuted,
  cursor: "pointer",
  display: "flex",
  alignItems: "center",
  gap: 3,
  fontWeight: 400,
}
```

| Button | Icon | Label | Color Override | Action |
| :---- | :---- | :---- | :---- | :---- |
| Download | Download (10px) | "Download" | (none) | Generates signed download URLs for all selected files and triggers browser download for each. If \>5 files, creates a zip (Edge Function). |
| Move | ArrowRight (10px) | "Move" | (none) | Opens Instance picker popover. Updates `instance_id` on all selected files to the chosen Instance. |
| Visibility | Eye (10px) | "Visibility" | (none) | Opens a small dropdown with 3 options (Visible, Instance, Hidden). Sets `visibility` on all selected files. |
| Delete | Trash2 (10px) | "Delete" | `color: "#ef4444"` | Opens confirmation dialog: "Delete {n} files? This action cannot be undone." On confirm: soft-deletes files (sets `deleted_at`) and removes from Storage. |

### Move Popover 

Triggered by the "Move" batch action button:

```ts
{
  position: "absolute",
  top: "100%",
  left: 0,
  marginTop: 4,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  padding: 6,
  minWidth: 200,
  maxHeight: 240,
  overflowY: "auto",
  boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
  zIndex: 20,
}
```

- Header: "Move to Instance" (11px, weight 500, `textMuted`, uppercase, padding 6px 10px)  
- "(No instance)" option: removes files from any instance (sets `instance_id = null`)  
- Instance list: each row has instance icon \+ name (12px, weight 400). Hover: `surfaceAlt` bg.

```ts
async function batchMoveFiles(fileIds: string[], instanceId: string | null) {
  const supabase = createClient();

  const { error } = await supabase
    .from("files")
    .update({ instance_id: instanceId })
    .in("id", fileIds);

  if (error) throw error;

  // Invalidate file list cache
  queryClient.invalidateQueries({ queryKey: ["global-files"] });

  // Show toast
  const instanceName = instanceId
    ? instances.find(i => i.id === instanceId)?.name
    : "No instance";
  showToast(`Moved ${fileIds.length} files to ${instanceName}`);

  // Clear selection
  setFileSelected([]);
}
```

### Visibility Dropdown 

Triggered by the "Visibility" batch action button:

```ts
{
  position: "absolute",
  top: "100%",
  left: 0,
  marginTop: 4,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  padding: 4,
  minWidth: 160,
  boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
  zIndex: 20,
}
```

3 options:

| Icon | Label | Description |
| :---- | :---- | :---- |
| Eye (12px) | "Visible" | "Shown everywhere" (10px, `textFaint`) |
| LayoutGrid (12px) | "Instance Only" | "Only in parent instance" |
| EyeOff (12px) | "Hidden" | "Only via direct link" |

Each option row: padding 7px 10px, borderRadius 6, display flex, gap 8px, cursor pointer.

### Delete Confirmation Dialog 

A modal overlay:

```ts
{
  position: "fixed",
  inset: 0,
  background: "rgba(0,0,0,0.4)",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  zIndex: 60,
}
```

Dialog:

```ts
{
  background: theme.surface,
  borderRadius: 14,
  padding: 24,
  maxWidth: 400,
  width: "90%",
  boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
}
```

- Title: "Delete {n} files?" (16px, weight 450, `text`)  
- Description: "This will permanently remove these files from storage. This action cannot be undone." (13px, weight 300, `textMuted`, lineHeight 1.5, marginTop 8px)  
- Buttons (flex, justifyContent flex-end, gap 8px, marginTop 20px):  
  - "Cancel" button: bordered, 12px, `textMuted`, padding 6px 16px  
  - "Delete" button: background \#ef4444, color white, border none, borderRadius 8, padding 6px 16px, fontSize 12, fontWeight 450

```ts
async function batchDeleteFiles(fileIds: string[]) {
  const supabase = createClient();

  // 1. Soft-delete in database
  const { error } = await supabase
    .from("files")
    .update({ deleted_at: new Date().toISOString() })
    .in("id", fileIds);

  if (error) throw error;

  // 2. Remove from Storage (hard delete)
  for (const fileId of fileIds) {
    const file = allFiles.find(f => f.id === fileId);
    if (file) {
      await supabase.storage.from("user-files").remove([file.storage_path]);
    }
  }

  // 3. Invalidate and clear
  queryClient.invalidateQueries({ queryKey: ["global-files"] });
  queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] });
  setFileSelected([]);
  setFileSelectMode(false);

  showToast(`Deleted ${fileIds.length} files`);
}
```

---

## 8.6 Filter Chips 

```ts
{
  display: "flex",
  gap: 5,
  marginBottom: 20,
  alignItems: "center",
}
```

3 filter chips:

| ID | Label |
| :---- | :---- |
| `all` | All |
| `uploaded` | Uploads |
| `ai_generated` | AI Generated |

Each chip:

```ts
{
  padding: "5px 12px",
  borderRadius: 20,
  fontSize: 12,
  border: "none",
  cursor: "pointer",
  background: isActive ? theme.accent : theme.surfaceAlt,
  color: isActive ? theme.accentText : theme.textMuted,
  fontWeight: 400,
  transition: "background 0.15s ease, color 0.15s ease",
}
```

Single-select. "All" is the default.

**File count** (after the last chip):

```ts
{
  fontSize: 10,
  color: theme.textFaint,
  marginLeft: 4,
}
// Text: "{n} files"
```

The count reflects the filtered result (after both origin filter and search query are applied).

---

## 8.7 File List 

### Data Query 

```ts
const { data: allFiles, isLoading } = useQuery({
  queryKey: ["global-files"],
  queryFn: async () => {
    const supabase = createClient();
    const { data, error } = await supabase
      .from("files")
      .select(`
        *,
        instance:instances(id, name)
      `)
      .eq("visibility", "visible")     // Global view: only visible files
      .is("deleted_at", null)
      .order("created_at", { ascending: false })
      .limit(200);
    if (error) throw error;
    return (data ?? []).map(f => ({
      ...f,
      instance_name: f.instance?.name ?? null,
    }));
  },
  staleTime: 30_000,
});
```

### File Row 

Each file in the list:

```ts
{
  padding: "12px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  alignItems: "center",
  gap: 12,
  cursor: fileSelectMode ? "pointer" : "default",
}
```

In select mode, clicking the entire row toggles the file's selection. Outside select mode, clicking the row opens the file preview.

### Checkbox (Select Mode Only) 

Visible when `fileSelectMode === true`:

```ts
{
  width: 16,
  height: 16,
  borderRadius: 4,
  border: isSelected ? "none" : `1.5px solid ${theme.border}`,
  background: isSelected ? theme.accent : "transparent",
  cursor: "pointer",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  flexShrink: 0,
  transition: "background 0.1s ease, border 0.1s ease",
}
```

Checkmark when selected: "✓" in `accentText`, 10px, weight 700\.

```ts
function toggleFileSelect(fileId: string) {
  setFileSelected(prev =>
    prev.includes(fileId)
      ? prev.filter(id => id !== fileId)
      : [...prev, fileId]
  );
}
```

### File Type Icon 

14px Lucide icon, `textFaint` color, flexShrink 0:

| Type | Icon |
| :---- | :---- |
| pdf | FileText |
| image | Image |
| doc | FileText |
| code | FileCode |
| audio | Music |
| video | Play |
| spreadsheet | Table |
| other | FileText |

```ts
function fileTypeIcon(type: FileRecord["type"]): JSX.Element {
  const ICONS: Record<string, typeof FileText> = {
    pdf: FileText,
    image: ImageIcon,
    doc: FileText,
    code: FileCode,
    audio: Music,
    video: Play,
    spreadsheet: Table,
    other: FileText,
  };
  const Icon = ICONS[type] || FileText;
  return <Icon size={14} />;
}
```

### File Info (flex: 1, minWidth 0\) 

**Name:**

```ts
{
  fontSize: 14,
  color: theme.text,
  fontWeight: 400,
  overflow: "hidden",
  textOverflow: "ellipsis",
  whiteSpace: "nowrap",
}
```

**Metadata row:**

```ts
{
  fontSize: 11,
  color: theme.textMuted,
  marginTop: 2,
  fontWeight: 300,
  display: "flex",
  alignItems: "center",
  gap: 8,
  flexWrap: "wrap",
}
```

Metadata items (left to right):

1. **File size** — human-readable:

```ts
function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
```

2. **Date** — short format: "Feb 13", "Jan 5", etc.

```ts
function shortDate(dateString: string): string {
  return new Date(dateString).toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
  });
}
```

3. **Instance name** (only if file belongs to an instance):

```ts
{
  cursor: "pointer",
  display: "flex",
  alignItems: "center",
  gap: 3,
}
// LayoutGrid icon (9px) + instance name text
// Click: navigate to instance detail, Files tab
```

4. **Origin badge:**

```ts
{
  padding: "1px 6px",
  borderRadius: 8,
  fontSize: 10,
  fontWeight: 400,
  background: origin === "ai_generated" ? theme.surfaceAlt : "transparent",
  color: theme.textMuted,
}
// Text: "AI" for ai_generated, "Upload" for uploaded, "Extract" for browser_extract
```

5. **Visibility indicator** (only when not "visible"):

```ts
// Instance-scoped
{
  display: "flex",
  alignItems: "center",
  gap: 2,
  fontSize: 10,
  color: theme.textFaint,
}
// LayoutGrid icon (9px) + "Instance only"

// Hidden
// EyeOff icon (9px) + "Hidden"
```

### Action Buttons (Right Side, Non-Select Mode) 

```ts
{
  display: "flex",
  gap: 4,
  flexShrink: 0,
}
```

Only visible when `fileSelectMode === false`. Each button:

```ts
{
  background: "none",
  border: "none",
  color: theme.textFaint,
  cursor: "pointer",
  display: "flex",
  padding: 3,
}
// Hover: color theme.textMuted
```

Two buttons:

| Icon | Title | Action |
| :---- | :---- | :---- |
| Download (13px) | "Download" | Generates a signed URL and triggers browser download |
| MoreHorizontal (13px) | "More" | Opens the file context menu |

### File Context Menu 

Triggered by the MoreHorizontal button on each file row:

```ts
{
  position: "absolute",
  right: 0,
  top: "100%",
  marginTop: 4,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  padding: 4,
  minWidth: 170,
  boxShadow: "0 4px 16px rgba(0,0,0,0.1)",
  zIndex: 10,
}
```

Menu items:

| Icon | Label | Action |
| :---- | :---- | :---- |
| Download (12px) | "Download" | Triggers browser download |
| Eye/EyeOff (12px) | "Change Visibility" | Opens visibility sub-menu |
| ArrowRight (12px) | "Move to Instance" | Opens instance picker sub-menu |
| Copy (12px) | "Copy Link" | Copies the signed download URL to clipboard |
| Edit3 (12px) | "Rename" | Inline rename (turns the name into an input field) |
| Tag (12px) | "Edit Tags" | Opens tag editor popover |
| Trash2 (12px, color \#ef4444) | "Delete" | Soft-deletes the file with confirmation |

Each menu item:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  padding: "7px 10px",
  borderRadius: 6,
  cursor: "pointer",
  fontSize: 12,
  color: isDanger ? "#ef4444" : theme.text,
  fontWeight: 400,
}
// Hover: background theme.surfaceAlt
```

### Inline Rename 

When "Rename" is selected from the context menu, the file name in the row transforms into an input:

```ts
{
  fontSize: 14,
  fontWeight: 400,
  color: theme.text,
  background: theme.surfaceAlt,
  border: `1px solid ${theme.accent}`,
  borderRadius: 6,
  padding: "2px 8px",
  outline: "none",
  width: "100%",
}
```

- Auto-focused with the filename (without extension) selected  
- Enter: saves the new name  
- Escape: cancels and reverts  
- Blur: saves the new name

```ts
async function renameFile(fileId: string, newName: string) {
  const supabase = createClient();

  await supabase
    .from("files")
    .update({ name: newName })
    .eq("id", fileId);

  queryClient.invalidateQueries({ queryKey: ["global-files"] });
}
```

### Tag Editor Popover 

Appears below the file row when "Edit Tags" is selected:

```ts
{
  position: "absolute",
  left: 40,
  top: "100%",
  marginTop: 4,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  padding: 12,
  minWidth: 240,
  boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
  zIndex: 20,
}
```

- Header: "Tags" (11px, weight 500, `textMuted`, uppercase)  
- Existing tags: flex wrap, gap 4px. Each tag:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 3,
  padding: "2px 8px",
  background: theme.surfaceAlt,
  borderRadius: 12,
  fontSize: 11,
  color: theme.textSec,
  fontWeight: 400,
}
// X button (9px, textFaint) to remove
```

- Add input: `surfaceAlt` bg, borderRadius 6, padding 4px 8px, fontSize 11, placeholder "Add tag...". On Enter: adds the tag to the file's `tags` array.

```ts
async function updateFileTags(fileId: string, tags: string[]) {
  const supabase = createClient();
  await supabase.from("files").update({ tags }).eq("id", fileId);
  queryClient.invalidateQueries({ queryKey: ["global-files"] });
}
```

---

## 8.8 File Upload 

### Upload Trigger Points 

Files can be uploaded from multiple locations throughout the platform:

1. **Global Files screen** — "Upload" button in the header  
2. **Instance Files tab** — "Upload" button (auto-attaches to that Instance)  
3. **Chat composer** — Paperclip button (auto-attaches to active chat and its Instance)  
4. **Drag-and-drop** — Dragging files onto the Global Files screen or any Instance view

All paths use the same `uploadFile()` function.

### Upload Function 

```ts
// src/lib/upload.ts
export async function uploadFile(
  file: File,
  options: {
    instanceId?: string;
    chatId?: string;
    messageId?: string;
    visibility?: FileRecord["visibility"];
  } = {}
): Promise<FileRecord> {
  const supabase = createClient();
  const userId = (await supabase.auth.getUser()).data.user?.id;
  if (!userId) throw new Error("Not authenticated");

  // 1. Validate file size against tier limits
  const tier = await getUserTier(userId);
  const maxSize = TIER_FILE_LIMITS[tier].maxFileSize;
  if (file.size > maxSize) {
    throw new UploadError(
      `File exceeds the ${formatFileSize(maxSize)} limit for your plan.`,
      "FILE_TOO_LARGE"
    );
  }

  // 2. Check total storage usage
  const { data: usage } = await supabase.rpc("get_storage_usage", {
    p_user_id: userId,
  });
  const maxStorage = TIER_FILE_LIMITS[tier].maxStorage;
  if ((usage ?? 0) + file.size > maxStorage) {
    throw new UploadError(
      `Upload would exceed your ${formatFileSize(maxStorage)} storage limit.`,
      "STORAGE_FULL"
    );
  }

  // 3. Upload to Supabase Storage
  const fileId = crypto.randomUUID();
  const storagePath = `${userId}/${fileId}/${file.name}`;

  const { error: uploadError } = await supabase.storage
    .from("user-files")
    .upload(storagePath, file, {
      contentType: file.type,
      upsert: false,
    });

  if (uploadError) throw uploadError;

  // 4. Insert file record
  const { data, error } = await supabase
    .from("files")
    .insert({
      user_id: userId,
      instance_id: options.instanceId ?? null,
      chat_id: options.chatId ?? null,
      message_id: options.messageId ?? null,
      name: file.name,
      type: inferFileType(file.type, file.name),
      mime_type: file.type,
      size_bytes: file.size,
      storage_path: storagePath,
      origin: "uploaded",
      visibility: options.visibility ?? "visible",
    })
    .select()
    .single();

  if (error) throw error;

  // 5. Activity log
  await supabase.from("activity_log").insert({
    user_id: userId,
    actor_type: "user",
    actor_name: "You",
    action: "uploaded a file",
    entity_type: "file",
    entity_id: data.id,
    entity_name: file.name,
    metadata: {
      file_type: data.type,
      file_size: file.size,
      instance_id: options.instanceId,
    },
  });

  // 6. Invalidate caches
  queryClient.invalidateQueries({ queryKey: ["global-files"] });
  queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] });

  return data;
}
```

### File Type Inference 

```ts
function inferFileType(mimeType: string, fileName: string): FileRecord["type"] {
  // MIME-based
  if (mimeType.startsWith("image/")) return "image";
  if (mimeType === "application/pdf") return "pdf";
  if (mimeType.startsWith("audio/")) return "audio";
  if (mimeType.startsWith("video/")) return "video";
  if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) return "spreadsheet";
  if (mimeType.includes("word") || mimeType.includes("document")) return "doc";

  // Extension-based fallback
  const ext = fileName.split(".").pop()?.toLowerCase();
  const CODE_EXTENSIONS = [
    "js", "ts", "jsx", "tsx", "py", "rb", "go", "rs", "java",
    "c", "cpp", "h", "cs", "php", "swift", "kt", "sh", "bash",
    "json", "yaml", "yml", "toml", "xml", "html", "css", "scss",
    "sql", "graphql", "md", "mdx",
  ];
  if (ext && CODE_EXTENSIONS.includes(ext)) return "code";

  const DOC_EXTENSIONS = ["doc", "docx", "rtf", "odt", "pages"];
  if (ext && DOC_EXTENSIONS.includes(ext)) return "doc";

  const SPREADSHEET_EXTENSIONS = ["xls", "xlsx", "csv", "tsv", "ods", "numbers"];
  if (ext && SPREADSHEET_EXTENSIONS.includes(ext)) return "spreadsheet";

  return "other";
}
```

### Storage Usage RPC 

```sql
CREATE OR REPLACE FUNCTION get_storage_usage(p_user_id UUID)
RETURNS BIGINT LANGUAGE sql STABLE AS $$
  SELECT COALESCE(SUM(size_bytes), 0)::BIGINT
  FROM files
  WHERE user_id = p_user_id
    AND deleted_at IS NULL;
$$;
```

### Size Limits by Tier 

| Tier | Max File Size | Total Storage |
| :---- | :---- | :---- |
| Free | 10 MB | 1 GB |
| Plus | 50 MB | 10 GB |
| Premium | 100 MB | 50 GB |
| Pro | 250 MB | 200 GB |

```ts
const TIER_FILE_LIMITS: Record<string, { maxFileSize: number; maxStorage: number }> = {
  free:    { maxFileSize: 10 * 1024 * 1024,    maxStorage: 1 * 1024 * 1024 * 1024 },
  plus:    { maxFileSize: 50 * 1024 * 1024,    maxStorage: 10 * 1024 * 1024 * 1024 },
  premium: { maxFileSize: 100 * 1024 * 1024,   maxStorage: 50 * 1024 * 1024 * 1024 },
  pro:     { maxFileSize: 250 * 1024 * 1024,   maxStorage: 200 * 1024 * 1024 * 1024 },
};
```

### Upload Error Handling 

```ts
class UploadError extends Error {
  code: string;
  constructor(message: string, code: string) {
    super(message);
    this.code = code;
  }
}
```

Error messages shown as toast notifications:

| Code | Message |
| :---- | :---- |
| `FILE_TOO_LARGE` | "File exceeds the {limit} limit for your plan. Upgrade for larger uploads." |
| `STORAGE_FULL` | "Not enough storage. You've used {used} of {total}. Upgrade for more space." |
| Network/Supabase error | "Upload failed. Please try again." |

The "Upgrade" text in the toast is a clickable link that opens the pricing modal.

### Drag-and-Drop Zone 

When the user drags a file over the screen, a full-screen drop zone overlay appears:

```ts
{
  position: "fixed",
  inset: 0,
  background: "rgba(0,0,0,0.3)",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  zIndex: 50,
  transition: "opacity 0.15s ease",
}
```

Inner:

```ts
{
  border: `2px dashed ${theme.accent}`,
  borderRadius: 16,
  padding: 48,
  display: "flex",
  flexDirection: "column",
  alignItems: "center",
  gap: 12,
}
```

- Upload icon (36px, `accent`)  
- "Drop file to upload" (16px, weight 400, `text`)

**Implementation:**

```ts
const [isDragging, setIsDragging] = useState(false);
const dragCounter = useRef(0);

function handleDragEnter(e: DragEvent) {
  e.preventDefault();
  dragCounter.current++;
  if (e.dataTransfer?.types.includes("Files")) {
    setIsDragging(true);
  }
}

function handleDragLeave(e: DragEvent) {
  e.preventDefault();
  dragCounter.current--;
  if (dragCounter.current === 0) {
    setIsDragging(false);
  }
}

function handleDrop(e: DragEvent) {
  e.preventDefault();
  setIsDragging(false);
  dragCounter.current = 0;

  const files = Array.from(e.dataTransfer?.files ?? []);
  for (const file of files) {
    uploadFile(file, { instanceId: currentInstanceId });
  }
}

useEffect(() => {
  const el = document.body;
  el.addEventListener("dragenter", handleDragEnter);
  el.addEventListener("dragleave", handleDragLeave);
  el.addEventListener("dragover", (e) => e.preventDefault());
  el.addEventListener("drop", handleDrop);
  return () => {
    el.removeEventListener("dragenter", handleDragEnter);
    el.removeEventListener("dragleave", handleDragLeave);
    el.removeEventListener("dragover", (e) => e.preventDefault());
    el.removeEventListener("drop", handleDrop);
  };
}, []);
```

The `dragCounter` ref pattern prevents the overlay from flickering when dragging over child elements.

### Upload Progress 

During upload, the file appears immediately in the list with a progress bar replacing the metadata row:

```ts
interface UploadingFile {
  id: string;           // Temporary ID
  name: string;
  size: number;
  progress: number;     // 0–100
  error?: string;
}

const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
```

The progress bar:

```ts
// Track
{
  height: 3,
  borderRadius: 2,
  background: theme.surfaceAlt,
  overflow: "hidden",
  marginTop: 4,
}

// Fill
{
  height: "100%",
  borderRadius: 2,
  background: theme.accent,
  width: `${progress}%`,
  transition: "width 0.2s ease",
}
```

When upload completes (progress \= 100), the uploading entry is removed and replaced by the real file record from the `files` table (via cache invalidation).

If upload fails, the progress bar turns red (`#ef4444` background) and the metadata row shows: "Upload failed" \+ "Retry" link (accent color, cursor pointer).

---

## 8.9 File Preview 

Clicking a file row (when not in select mode) opens a preview modal. The preview behavior depends on file type.

### Preview Modal Container 

```ts
{
  position: "fixed",
  inset: 0,
  background: "rgba(0,0,0,0.6)",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  zIndex: 60,
}
// Click backdrop (outside content) to close
```

### Top Bar 

```ts
{
  position: "absolute",
  top: 0,
  left: 0,
  right: 0,
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  padding: "12px 20px",
  background: "rgba(0,0,0,0.4)",
  backdropFilter: "blur(8px)",
  zIndex: 2,
}
```

- File name: 14px, weight 400, white  
- Right side (gap 8px):  
  - Download button: Download icon (16px, white, cursor pointer, background none, border none)  
  - Close button: X icon (18px, white, cursor pointer, background none, border none)

### Type-Specific Preview 

| Type | Preview Implementation |
| :---- | :---- |
| **image** | `<img>` tag centered, `maxWidth: "80vw", maxHeight: "80vh", objectFit: "contain"`. Supports zoom on click (toggle between fit and actual size). |
| **pdf** | `<iframe>` embedding the PDF via signed URL. `width: 80vw, height: 80vh, borderRadius: 8, border: none`. Falls back to download if iframe fails. |
| **code** | Syntax-highlighted code viewer using a lightweight library (e.g., `highlight.js` or `prism.js`). `maxWidth: 80vw, maxHeight: 80vh, overflow: auto, background: #1e1e1e, borderRadius: 8, padding: 20px, fontSize: 13, fontFamily: monospace, lineHeight: 1.5`. Language auto-detected from file extension. |
| **doc** | Download prompt: "Preview isn't available for this file type." \+ "Download" button. |
| **spreadsheet** | If CSV/TSV: render as an HTML table (first 50 rows, scrollable). Otherwise: download prompt. |
| **audio** | HTML5 `<audio>` player with controls. `width: 400px, borderRadius: 8`. |
| **video** | HTML5 `<video>` player with controls. `maxWidth: 80vw, maxHeight: 80vh, borderRadius: 8`. |
| **other** | Download prompt (same as doc). |

### Image Preview Detail 

```ts
function ImagePreview({ file }: { file: FileRecord }) {
  const [zoomed, setZoomed] = useState(false);
  const signedUrl = useSignedUrl(file.storage_path);

  return (
    <img
      src={signedUrl}
      alt={file.name}
      onClick={() => setZoomed(!zoomed)}
      style={{
        maxWidth: zoomed ? "none" : "80vw",
        maxHeight: zoomed ? "none" : "80vh",
        objectFit: "contain",
        cursor: zoomed ? "zoom-out" : "zoom-in",
        borderRadius: 4,
      }}
    />
  );
}
```

### Signed URL Generation 

File previews and downloads use signed URLs (time-limited access):

```ts
function useSignedUrl(storagePath: string, expiresIn: number = 3600) {
  const { data: url } = useQuery({
    queryKey: ["signed-url", storagePath],
    queryFn: async () => {
      const supabase = createClient();
      const { data, error } = await supabase.storage
        .from("user-files")
        .createSignedUrl(storagePath, expiresIn);
      if (error) throw error;
      return data.signedUrl;
    },
    staleTime: (expiresIn - 60) * 1000,  // Refresh 60s before expiry
  });
  return url;
}
```

---

## 8.10 AI-Generated Files 

When a persona generates a file during a chat conversation (e.g., Sally creates a mockup, Dev generates a code file), the file is automatically created and linked.

### Generation Flow 

1. Persona's AI response includes a file generation intent (detected by Cipher)  
2. The Edge Function generates the file content (image, code, document)  
3. File is saved to Supabase Storage  
4. File record is inserted into the `files` table with `origin: 'ai_generated'`  
5. File is linked to the chat (`chat_id`), message (`message_id`), and Instance (`instance_id`)  
6. A `file_reference` content block is created in the message (Part 5\)  
7. Activity log entry created

### Implementation 

```ts
// Called from the AI response pipeline (Part 5, Section 5.8)
async function createAIGeneratedFile(
  content: Buffer | string,
  fileName: string,
  mimeType: string,
  context: {
    userId: string;
    chatId: string;
    messageId: string;
    instanceId?: string;
    personaId: string;
    personaName: string;
    generationPrompt?: string;
  }
): Promise<FileRecord> {
  const supabase = createServiceClient();
  const fileId = crypto.randomUUID();
  const storagePath = `${context.userId}/${fileId}/${fileName}`;

  // 1. Upload to storage
  const fileBuffer = typeof content === "string"
    ? new TextEncoder().encode(content)
    : content;

  await supabase.storage
    .from("user-files")
    .upload(storagePath, fileBuffer, { contentType: mimeType });

  // 2. Insert file record
  const { data: file } = await supabase
    .from("files")
    .insert({
      user_id: context.userId,
      instance_id: context.instanceId ?? null,
      chat_id: context.chatId,
      message_id: context.messageId,
      name: fileName,
      type: inferFileType(mimeType, fileName),
      mime_type: mimeType,
      size_bytes: fileBuffer.byteLength,
      storage_path: storagePath,
      origin: "ai_generated",
      visibility: "visible",
      metadata: {
        generating_persona_id: context.personaId,
        generating_persona_name: context.personaName,
        generation_prompt: context.generationPrompt?.slice(0, 500),
      },
    })
    .select()
    .single();

  // 3. Activity log
  await supabase.from("activity_log").insert({
    user_id: context.userId,
    actor_type: "persona",
    actor_id: context.personaId,
    actor_name: context.personaName,
    action: `generated ${fileName}`,
    entity_type: "file",
    entity_id: file.id,
    entity_name: fileName,
    metadata: { instance_id: context.instanceId },
  });

  return file;
}
```

AI-generated files inherit the Instance's default visibility setting. The user can change visibility at any time via the file context menu.

---

## 8.11 Download 

### Single File Download 

```ts
async function downloadFile(file: FileRecord) {
  const supabase = createClient();

  const { data, error } = await supabase.storage
    .from("user-files")
    .createSignedUrl(file.storage_path, 60); // 60-second URL

  if (error) {
    showToast("Download failed. Please try again.");
    return;
  }

  // Trigger browser download
  const a = document.createElement("a");
  a.href = data.signedUrl;
  a.download = file.name;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
}
```

### Batch Download 

When \>1 files are selected and "Download" is clicked:

If 1–5 files: download each individually (sequential signed URL generation \+ browser download triggers).

If \>5 files: call an Edge Function that creates a zip archive and returns a download URL:

```ts
async function batchDownload(fileIds: string[]) {
  if (fileIds.length <= 5) {
    // Sequential individual downloads
    for (const id of fileIds) {
      const file = allFiles.find(f => f.id === id);
      if (file) await downloadFile(file);
    }
  } else {
    // Zip via Edge Function
    const supabase = createClient();
    const { data } = await supabase.functions.invoke("files-zip", {
      body: { file_ids: fileIds },
    });
    if (data?.download_url) {
      window.location.href = data.download_url;
    }
  }
}
```

---

## 8.12 Empty States 

### No Files At All (New User) 

```ts
{
  padding: "60px 20px",
  textAlign: "center",
}
```

- Upload icon (32px, `textFaint`, marginBottom 14px)  
- "No files yet" (16px, weight 400, `text`)  
- "Upload files or let your Personas generate them during conversations." (13px, weight 300, `textMuted`, marginTop 8px, lineHeight 1.5, maxWidth 380px, margin auto)  
- "Upload" button (accent bg, accentText, borderRadius 8, padding 8px 20px, fontSize 12, fontWeight 450, marginTop 20px). Opens the native file picker.

### No Files Match Filter 

When files exist but the current filter \+ search combination yields zero results:

```ts
{
  padding: 48,
  textAlign: "center",
  color: theme.textFaint,
  fontSize: 13,
  fontWeight: 300,
}
// Text: "No files match this filter."
```

### Loading State 

3 skeleton file rows:

```ts
// Each skeleton row
{
  padding: "12px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  alignItems: "center",
  gap: 12,
}
```

- Icon placeholder: 14×14px square (surfaceAlt bg, borderRadius 3, pulse animation)  
- Name bar: 200px × 14px (surfaceAlt bg, borderRadius 4, pulse)  
- Metadata bar: 140px × 10px (surfaceAlt bg, borderRadius 4, pulse, marginTop 4px)

---

## 8.13 Storage Usage Indicator 

A subtle storage usage bar appears at the bottom of the file list when the user has consumed \>50% of their tier's storage allowance:

```ts
{
  marginTop: 24,
  padding: "12px 0",
  borderTop: `1px solid ${theme.borderSubtle}`,
}
```

- Usage text: "{used} of {total} used" (11px, weight 300, `textFaint`)  
- Progress bar:

```ts
// Track
{
  height: 3,
  borderRadius: 2,
  background: theme.surfaceAlt,
  marginTop: 6,
}

// Fill
{
  height: "100%",
  borderRadius: 2,
  background: percentage > 90 ? "#ef4444" : percentage > 75 ? "#f59e0b" : theme.accent,
  width: `${percentage}%`,
}
```

Color coding: accent (normal), amber (\>75%), red (\>90%).

If \>90%, an additional warning: "Storage almost full. Upgrade for more space." (11px, weight 350, `textMuted`, marginTop 4px) with "Upgrade" as a clickable accent-colored link to the pricing modal.

---

*End of Part 8\. Proceed to Part 9: People / Personas Screen.*

# 

# PART 9: PEOPLE / PERSONAS SCREEN 

---

Personas are the heart of aiConnected. They are persistent AI collaborators with defined identities, bounded skills, accumulated memories, and explicit behavioral rules. The People screen is where users create, configure, monitor, and manage these personas. This part covers the complete data model, list view, six-tab detail view, creation wizard, and template system.

---

## 9.1 Data Model 

### Personas Table 

```sql
CREATE TABLE public.personas (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT '',
  purpose TEXT DEFAULT '',                      -- What this persona is designed to do
  voice TEXT DEFAULT '',                         -- Communication style description
  personality TEXT DEFAULT '',                   -- Long-form personality description
  avatar_config JSONB DEFAULT '{}'::jsonb,       -- Future: custom avatar settings
  status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'idle', 'sleeping')),
  mood TEXT DEFAULT 'Neutral',
  skill_ceiling INTEGER NOT NULL DEFAULT 10,     -- Maximum number of skills allowed
  visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'shared')),
  identity_locked BOOLEAN NOT NULL DEFAULT false,-- Prevents accidental edits to name/role
  can_learn BOOLEAN NOT NULL DEFAULT true,       -- Whether this persona can propose new skills
  settings JSONB DEFAULT '{}'::jsonb,            -- Persona-specific overrides
  archived_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.personas ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own personas"
  ON public.personas FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_personas_user ON public.personas(user_id, updated_at DESC);

CREATE TRIGGER set_personas_updated_at
  BEFORE UPDATE ON public.personas FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### Persona Skills Table 

Skills are discrete capabilities that a persona can use. They have levels (1–5), can be permanent or temporary, and may be learnable (can improve with use).

```sql
CREATE TABLE public.persona_skills (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  category TEXT DEFAULT 'general',              -- e.g. "design", "writing", "code", "research"
  level INTEGER NOT NULL DEFAULT 1 CHECK (level BETWEEN 1 AND 5),
  learnable BOOLEAN NOT NULL DEFAULT true,       -- Can this skill improve through use?
  temporary BOOLEAN NOT NULL DEFAULT false,      -- Temporary skills expire
  expires_at TIMESTAMPTZ,                        -- When a temporary skill expires
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(persona_id, name)
);

ALTER TABLE public.persona_skills ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage skills on own personas"
  ON public.persona_skills FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.personas
      WHERE personas.id = persona_skills.persona_id
      AND personas.user_id = auth.uid()
    )
  );

CREATE INDEX idx_skills_persona ON public.persona_skills(persona_id);
```

### Persona Boundaries Table 

Boundaries define explicit behavioral rules — what a persona should do, shouldn't do, and when to escalate.

```sql
CREATE TABLE public.persona_boundaries (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  type TEXT NOT NULL CHECK (type IN ('will_do', 'wont_do', 'escalation')),
  description TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.persona_boundaries ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage boundaries on own personas"
  ON public.persona_boundaries FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.personas
      WHERE personas.id = persona_boundaries.persona_id
      AND personas.user_id = auth.uid()
    )
  );

CREATE INDEX idx_boundaries_persona ON public.persona_boundaries(persona_id);
```

### Persona Memories Table 

Memories are knowledge nodes extracted from conversations. They form the persona-specific layer of CogniGraph (the full CogniGraph architecture is detailed in Part 15).

```sql
CREATE TABLE public.persona_memories (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  type TEXT NOT NULL CHECK (type IN ('decision', 'fact', 'preference', 'skill')),
  content TEXT NOT NULL,
  source_chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  source_message_id UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  confidence TEXT NOT NULL DEFAULT 'medium' CHECK (confidence IN ('strong', 'medium', 'weak')),
  tags TEXT[] DEFAULT '{}',
  active BOOLEAN NOT NULL DEFAULT true,          -- Inactive memories are kept but not used in context
  embedding vector(1536),                        -- For semantic search (requires pgvector extension)
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.persona_memories ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage memories on own personas"
  ON public.persona_memories FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.personas
      WHERE personas.id = persona_memories.persona_id
      AND personas.user_id = auth.uid()
    )
  );

CREATE INDEX idx_memories_persona ON public.persona_memories(persona_id, created_at DESC);
CREATE INDEX idx_memories_type ON public.persona_memories(persona_id, type);
CREATE INDEX idx_memories_active ON public.persona_memories(persona_id) WHERE active = true;
CREATE INDEX idx_memories_embedding ON public.persona_memories USING ivfflat (embedding vector_cosine_ops);
```

### Persona Health Metrics Table 

Tracks historical health data for the Health tab visualizations.

```sql
CREATE TABLE public.persona_health_snapshots (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  memory_stability NUMERIC(5,2),                 -- % of memories that haven't been contradicted
  skill_stability NUMERIC(5,2),                  -- % of skills actively used recently
  mood TEXT,
  drift_level TEXT CHECK (drift_level IN ('low', 'medium', 'high')),
  snapshot_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_health_persona ON public.persona_health_snapshots(persona_id, snapshot_at DESC);
```

### TypeScript Types 

```ts
// src/types/persona.ts

interface Persona {
  id: string;
  user_id: string;
  name: string;
  role: string;
  purpose: string;
  voice: string;
  personality: string;
  avatar_config: Record<string, unknown>;
  status: "active" | "idle" | "sleeping";
  mood: string;
  skill_ceiling: number;
  visibility: "private" | "shared";
  identity_locked: boolean;
  can_learn: boolean;
  settings: Record<string, unknown>;
  archived_at: string | null;
  created_at: string;
  updated_at: string;
  // Aggregated data (from queries)
  skills_count?: number;
  memories_count?: number;
  decisions_count?: number;
}

interface PersonaSkill {
  id: string;
  persona_id: string;
  name: string;
  category: string;
  level: number;              // 1-5
  learnable: boolean;
  temporary: boolean;
  expires_at: string | null;
  created_at: string;
}

interface PersonaBoundary {
  id: string;
  persona_id: string;
  type: "will_do" | "wont_do" | "escalation";
  description: string;
  created_at: string;
}

interface PersonaMemory {
  id: string;
  persona_id: string;
  type: "decision" | "fact" | "preference" | "skill";
  content: string;
  source_chat_id: string | null;
  source_message_id: string | null;
  confidence: "strong" | "medium" | "weak";
  tags: string[];
  active: boolean;
  created_at: string;
  updated_at: string;
  // Joined
  source_chat_title?: string;
}
```

---

## 9.2 Persona List View 

### Container 

```ts
{
  padding: `${py}px ${px}px`,
  overflowY: "auto",
  height: "100%",
  maxWidth: 900,
  margin: "0 auto",
  position: "relative",
}
```

### Header 

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "baseline",
  marginBottom: 40,
}
```

- Title: "People" (28px, weight 300, letter-spacing \-0.03em)  
- "+New" button: Plus icon (14px) \+ "New" text. Opens the Create Persona modal.

### Persona Items 

Each persona is a clickable row:

```ts
{
  padding: "20px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  cursor: "pointer",
  display: "flex",
  alignItems: "center",
  gap: 16,
}
```

- 40px Avatar circle (initial-based)  
- Content (flex: 1):  
  - Name \+ StatusDot: 16px, weight 450, `text` color. StatusDot inline after name (6px).  
  - Metadata: `{role} · {skills}/{maxSkills} skills · {memories} memories` (13px, weight 300, `textMuted`, marginTop 2px)  
- ChevronRight (16px, `textFaint`)

Clicking navigates to persona detail: `go("people", { persona: p.id })`.

### Data Query 

```ts
const { data: personas } = useQuery({
  queryKey: ["personas"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("personas")
      .select(`
        *,
        skills:persona_skills(count),
        memories:persona_memories(count),
        decisions:persona_memories(count).eq(type, 'decision')
      `)
      .is("archived_at", null)
      .order("updated_at", { ascending: false });
    return data ?? [];
  },
});
```

### Tier Limits 

| Tier | Max Personas |
| :---- | :---- |
| Free | 2 |
| Plus | 5 |
| Premium | 15 |
| Pro | Unlimited |

When the user is at their tier limit, the "+New" button shows a lock icon and clicking opens the pricing modal.

### Persona Templates Section 

Below the persona list, a section for pre-built and saved templates:

**Header card:**

```ts
{
  marginTop: 28,
  padding: "14px 16px",
  background: theme.surfaceAlt,
  borderRadius: 10,
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
}
```

- Title: "Persona Templates" (13px, weight 450\)  
- Description: "Save and share Persona configurations" (12px, weight 300, `textMuted`)  
- "Browse" button: bordered, 11px

**Template chips:** Row of quick-create template cards (wrapping flex, gap 8px):

```ts
// Each template chip
{
  padding: "8px 14px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  cursor: "pointer",
  border: `1px solid ${theme.borderSubtle}`,
}
```

- Template name: 11px, weight 450, `text`  
- Description: 9px, weight 300, `textFaint`

Clicking a template opens the Create Persona modal pre-filled with the template's values.

### Templates Schema 

```sql
CREATE TABLE public.persona_templates (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,  -- NULL for system templates
  name TEXT NOT NULL,
  description TEXT DEFAULT '',
  config JSONB NOT NULL DEFAULT '{}'::jsonb,  -- Full persona configuration
  is_system BOOLEAN NOT NULL DEFAULT false,    -- System-provided templates
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.persona_templates ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can read system templates and own templates"
  ON public.persona_templates FOR SELECT
  USING (is_system = true OR auth.uid() = user_id);

CREATE POLICY "Users manage own templates"
  ON public.persona_templates FOR ALL
  USING (auth.uid() = user_id);
```

**Template config JSON structure:**

```ts
interface PersonaTemplateConfig {
  role: string;
  purpose: string;
  voice: string;
  personality: string;
  initial_skills: { name: string; category: string; level: number }[];
  boundaries: {
    will_do: string[];
    wont_do: string[];
    escalation: string;
  };
  skill_ceiling: number;
}
```

---

## 9.3 Persona Detail View 

### Container 

```ts
{
  display: "flex",
  flexDirection: "column",
  height: "100%",
  maxWidth: 800,
  margin: "0 auto",
  width: "100%",
}
```

### Header 

```ts
{
  padding: `${py}px ${px}px 0`,
  flexShrink: 0,
}
```

**Profile row:** flex, alignItems flex-start, gap 16px, marginBottom 24px.

- 52px Avatar circle  
- Content (flex: 1):  
  - Name: 24px, weight 300, letter-spacing \-0.02em  
  - Metadata: `{role}` \+ StatusDot (6px) \+ `{mood}` — 13px, weight 300, `textMuted`, gap 8px, marginTop 2px  
- Action buttons (flexShrink 0, gap 6px):  
  - "Chat" button: MessageSquare icon (12px) \+ "Chat" text. `border: 1px solid theme.border, borderRadius: 8, padding: 6px 14px, fontSize: 12, color: textSec`. Creates a new chat with this persona as the sole participant.  
  - Overflow menu button: MoreHorizontal icon (16px), bordered.

**Overflow menu dropdown:** (absolute positioned, right-aligned, 170px min-width)

| Icon | Label | Action |
| :---- | :---- | :---- |
| Edit3 (12px) | Edit Identity | Opens Identity tab in edit mode |
| Eye/EyeOff (12px) | Enable/Disable | Toggles status between active and sleeping |
| Copy (12px) | Save as Template | Creates a template from this persona's current configuration |
| Archive (12px) | Archive | Sets `archived_at` to now() |
| Trash2 (12px) | Delete | Destructive action, colored \#ef4444. Requires confirmation. |

### Tab Navigation 

6 tabs below the header:

```ts
{
  display: "flex",
  gap: mobile ? 14 : 24,
  borderBottom: `1px solid ${theme.borderSubtle}`,
  overflowX: "auto",
}
```

Each tab: same styling as Instance tabs (Part 7, Section 7.3).

Tabs: **Overview**, **Identity**, **Boundaries**, **Memory**, **Skills**, **Health**

### Tab Content Container 

```ts
{
  flex: 1,
  overflowY: "auto",
  padding: `24px ${px}px ${py}px`,
}
```

---

## 9.4 Overview Tab 

The landing tab for a persona. Shows key stats, deployments, skills summary, and recent conversations.

### Quick Stats 

Three stat blocks in a horizontal row (gap 48px desktop, 24px mobile):

```ts
// Each stat block
{
  // Number
  fontSize: 32,
  fontWeight: 200,
  color: theme.text,
  letterSpacing: "-0.04em",
  lineHeight: 1,
}
// Label
{
  fontSize: 12,
  color: theme.textMuted,
  marginTop: 6,
  fontWeight: 300,
}
```

| Stat | Value Source |
| :---- | :---- |
| Memories | `COUNT(*) FROM persona_memories WHERE persona_id = ? AND active = true` |
| Decisions | `COUNT(*) FROM persona_memories WHERE persona_id = ? AND type = 'decision'` |
| Skills | `{current_count}/{skill_ceiling}` |

### Deployments Section 

Header: "DEPLOYMENTS" (11px, weight 500, `textMuted`, uppercase, letter-spacing 0.1em, marginBottom 12px).

Query:

```sql
SELECT ip.role_in_instance, ip.deployed_at, i.name AS instance_name, i.id AS instance_id
FROM instance_personas ip
JOIN instances i ON i.id = ip.instance_id
WHERE ip.persona_id = ?
AND ip.removed_at IS NULL
ORDER BY ip.deployed_at DESC;
```

Each deployment is a chip:

```ts
{
  padding: "6px 14px",
  background: theme.surfaceAlt,
  borderRadius: 10,
  fontSize: 12,
}
```

- Instance name: weight 400, `text`  
- Metadata:  `· {role} · since {date}` — weight 300, `textFaint`

Clicking a deployment chip navigates to the Instance detail view.

Deployments row has `flexWrap: wrap, gap: 8, marginBottom: 32`.

### Skills Summary 

Header: "SKILLS" (same section header style, marginBottom 16px).

Skills displayed as pill chips in a wrapping row (gap 8px, marginBottom 40px):

```ts
{
  padding: "5px 14px",
  background: theme.surfaceAlt,
  borderRadius: 20,
  fontSize: 12,
  color: theme.textSec,
  fontWeight: 400,
}
```

Shows up to `skill_ceiling` skills. Clicking a skill chip navigates to the Skills tab.

### Recent Conversations 

Header: "RECENT CONVERSATIONS" (same section header style, marginBottom 16px).

Query:

```sql
SELECT DISTINCT c.id, c.title, c.updated_at, i.name AS instance_name
FROM chats c
JOIN chat_participants cp ON cp.chat_id = c.id
LEFT JOIN instances i ON i.id = c.instance_id
WHERE cp.persona_id = ?
AND cp.removed_at IS NULL
AND c.deleted_at IS NULL
ORDER BY c.updated_at DESC
LIMIT 5;
```

Each conversation item:

```ts
{
  padding: "12px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  justifyContent: "space-between",
}
```

- Left side:  
  - Title: 14px, weight 400, `text`  
  - Instance name: 12px, weight 300, `textMuted`, marginTop 2px  
- Right side: timestamp (11px, weight 300, `textFaint`)

Clicking navigates to the chat.

---

## 9.5 Identity Tab 

The Identity tab shows the persona's core definition fields and visibility settings.

### Section Header 

```ts
{
  marginBottom: 28,
}
```

- Title: "Core Identity" (14px, weight 450\)  
- Description: "Define who this Persona is. Changes here affect how they communicate and present themselves." (12px, weight 300, `textMuted`)

### Identity Fields 

Each field is a row:

```ts
{
  padding: "16px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  justifyContent: "space-between",
  alignItems: "flex-start",
  gap: 20,
}
```

Left side (flex: 1):

- Label: 12px, weight 400, `textMuted`, uppercase, letter-spacing 0.05em, marginBottom 4px  
- Value: 14px, weight 350, `text`, line-height 1.5

Right side:

- "Edit" button: `border: 1px solid theme.border, borderRadius: 6, padding: 4px 10px, fontSize: 11, color: textMuted, flexShrink: 0`

4 fields:

| Label | Column | Description |
| :---- | :---- | :---- |
| NAME | `name` | The persona's display name |
| ROLE | `role` | Professional role title |
| PURPOSE | `purpose` | What this persona is designed to do |
| VOICE | `voice` | Communication style description |

**Edit mode:** When the user clicks "Edit" on a field, the value text is replaced with an inline input (for Name/Role) or textarea (for Purpose/Voice). Save and Cancel buttons appear below.

```ts
// Edit mode for single-line fields
<input
  defaultValue={currentValue}
  style={{
    width: "100%",
    padding: "8px 12px",
    background: theme.surfaceAlt,
    border: `1px solid ${theme.border}`,
    borderRadius: 8,
    fontSize: 14,
    color: theme.text,
    outline: "none",
  }}
/>

// Edit mode for multi-line fields
<textarea
  defaultValue={currentValue}
  rows={3}
  style={{
    width: "100%",
    padding: "8px 12px",
    background: theme.surfaceAlt,
    border: `1px solid ${theme.border}`,
    borderRadius: 8,
    fontSize: 14,
    color: theme.text,
    outline: "none",
    resize: "vertical",
    fontFamily: "inherit",
  }}
/>
```

Save button: accent bg, 11px, weight 450\. Cancel button: bordered, 11px.

### Lock Identity Toggle 

Below the identity fields:

```ts
{
  padding: "16px 0",
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
}
```

- Label: "Lock Identity" (13px, weight 450, `text`)  
- Description: "Prevent accidental edits to name and role" (12px, weight 300, `textMuted`, marginTop 2px)  
- Toggle: ToggleLeft/ToggleRight icon (20px). When locked, the Edit buttons on Name and Role fields are disabled.

Stored in `personas.identity_locked`.

### Visibility Selector 

```ts
{
  padding: "16px 0",
  borderTop: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
}
```

- Label: "Visibility" (13px, weight 450, `text`)  
- Description: "Who can see and interact with this Persona" (12px, weight 300, `textMuted`, marginTop 2px)  
- Selector: Two segmented buttons side by side (gap 4px):  
  - "Private": When active: accent bg, accentText. When inactive: bordered, `textMuted`.  
  - "Shared": Same toggle pattern.  
  - Each: `padding: 4px 10px, borderRadius: 6, fontSize: 11`

Stored in `personas.visibility`.

---

## 9.6 Boundaries Tab 

The Boundaries tab defines explicit behavioral rules for the persona. This is a critical safety feature of aiConnected — personas operate within clear limits.

### Section Header 

- Title: "Boundaries & Safety" (14px, weight 450\)  
- Description: "Define what this Persona should and shouldn't do." (12px, weight 300, `textMuted`)  
- marginBottom: 28px

### Will Do Section 

```ts
{
  marginBottom: 28,
}
```

**Header:** Shield icon (13px) \+ "Will do" text (12px, weight 500, colored green `dot.active`), marginBottom 10px.

Each boundary rule:

```ts
{
  padding: "8px 0",
  fontSize: 13,
  color: theme.text,
  fontWeight: 350,
  display: "flex",
  alignItems: "center",
  gap: 8,
}
```

- Green dot: 4px circle, `dot.active` background, borderRadius 2px  
- Rule text

**Add button:** `border: 1px dashed theme.border, borderRadius: 6, padding: 4px 10px, fontSize: 11, color: textMuted`, marginTop 8px. Plus icon (10px) \+ "Add".

Clicking opens an inline text input below the list. Press Enter to save.

### Won't Do Section 

Same structure as Will Do, but with:

- Header color: \#ef4444 (red)  
- Dot color: \#ef4444

### Escalation Rule 

A highlighted card below the Won't Do list:

```ts
{
  padding: 16,
  background: theme.surfaceAlt,
  borderRadius: 10,
  marginBottom: 24,
}
```

- Label: "Escalation Rule" (12px, weight 450, `text`, marginBottom 4px)  
- Rule text: 13px, weight 350, `textSec`

This defines when the persona should defer to the user or suggest another persona instead of attempting to handle the request. Clicking opens an inline editor.

Stored in `persona_boundaries` with `type = 'escalation'`. Only one escalation rule per persona.

### Skill Ceiling Control 

Below the escalation rule, side by side with the Can Learn toggle (gap 32px desktop, 16px mobile):

**Skill Ceiling:**

- Label: "Skill Ceiling" (12px, weight 400, `textMuted`, marginBottom 6px)  
- Progress bar (width 120px, height 4px, borderRadius 2px, `surfaceAlt` background) with accent-colored fill  
- Value: `{ceiling}/10` (12px, weight 400, `text`)

The ceiling is adjustable via a number input or slider (v2). In v1, clicking opens an inline number input.

Stored in `personas.skill_ceiling`.

**Can Learn:**

- Label: "Can Learn" (12px, weight 400, `textMuted`, marginBottom 6px)  
- Toggle: ToggleLeft/ToggleRight (20px). Green when enabled, `textFaint` when disabled.

When enabled, the persona can propose new skills through a learning consent flow (see Section 9.8 Skills Tab). When disabled, the persona's skill set is fixed.

Stored in `personas.can_learn`.

### Boundary CRUD Operations 

```ts
// Add boundary
async function addBoundary(personaId: string, type: string, description: string) {
  const supabase = createClient();
  return supabase.from("persona_boundaries").insert({
    persona_id: personaId,
    type,
    description,
  });
}

// Update boundary
async function updateBoundary(id: string, description: string) {
  const supabase = createClient();
  return supabase.from("persona_boundaries")
    .update({ description })
    .eq("id", id);
}

// Delete boundary
async function deleteBoundary(id: string) {
  const supabase = createClient();
  return supabase.from("persona_boundaries")
    .delete()
    .eq("id", id);
}
```

---

## 9.7 Memory Tab 

The Memory tab is the persona's knowledge ledger. It shows every piece of information the persona has learned from conversations.

### Header Row 

```ts
{
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  marginBottom: 14,
  flexWrap: "wrap",
  gap: 10,
}
```

**Left side:**

- Memory count: `{n} memories` (14px, weight 400\)  
- Select button: Square icon (10px) \+ "Select" (or `{n} selected` when active). Accent bg when active.

**Right side (filter chips):**

5 chips:

| ID | Label |
| :---- | :---- |
| `all` | All |
| `decision` | Decisions |
| `fact` | Facts |
| `preference` | Prefs |
| `skill` | Skills |

Each chip:

```ts
{
  padding: "5px 12px",
  borderRadius: 20,
  fontSize: 12,
  cursor: "pointer",
  fontWeight: 400,
  background: isActive ? theme.accent : "transparent",
  color: isActive ? theme.accentText : theme.textMuted,
  border: isActive ? "none" : `1px solid ${theme.border}`,
}
```

### Batch Actions Bar 

Visible when select mode is active and at least one memory is selected:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  marginBottom: 14,
  padding: "8px 12px",
  background: theme.surfaceAlt,
  borderRadius: 8,
}
```

- Count: `{n} selected` (11px, weight 450\)  
- Archive button: Archive icon (10px) \+ "Archive" (10px), bordered  
- Delete button: Trash2 icon (10px) \+ "Delete" (10px, \#ef4444), bordered  
- Flag button: Shield icon (10px) \+ "Flag" (10px), bordered. Marks memories as potentially harmful or inaccurate.

### Memory Items 

Each memory card:

```ts
{
  padding: "18px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
}
```

**Content row:** flex, gap 12px, alignItems flex-start.

**Checkbox (select mode only):** Same 16px checkbox pattern as files.

**Type icon:** Memory type icon component (13px, `textMuted`):

| Type | Icon |
| :---- | :---- |
| decision | GitBranch |
| fact | Brain |
| preference | Heart |
| skill | Zap |
| (fallback) | Hash |

**Content area (flex: 1):**

- **Normal mode:** Memory text (14px, weight 400, `text`, line-height 1.6, marginBottom 8px)  
- **Edit mode:** Textarea with the memory text pre-filled (13px, `surfaceAlt` background, border, borderRadius 8, padding 8px 10px, min-height 60px, resize vertical). Below: Save button (accent bg, 11px, weight 450\) \+ Cancel button (bordered, 11px), gap 6px, marginTop 6px.

**Metadata row** (below content, flex, alignItems center, gap 10px, flexWrap wrap):

- Type label: 11px, weight 400, `textMuted`, capitalize  
- Confidence: 11px, weight 300\. `textSec` for "strong", `textFaint` for "medium"/"weak"  
- Date: 11px, weight 300, `textFaint`

**Source link** (below metadata, marginTop 8px):

```ts
{
  display: "inline-flex",
  alignItems: "center",
  gap: 5,
  fontSize: 11,
  color: theme.textMuted,
  cursor: "pointer",
  fontWeight: 400,
  padding: "4px 10px",
  borderRadius: 16,
  background: theme.surfaceAlt,
}
```

- MessageSquare icon (10px) \+ chat title \+ ExternalLink icon (8px)  
- Clicking navigates to the source chat

**Action buttons** (right side of content row, flexShrink 0, marginTop 2px, gap 2px):

| Icon | Tooltip | Action |
| :---- | :---- | :---- |
| Edit3 (12px) | Edit | Toggles edit mode on this memory |
| Shield (12px) | Flag as harmful | Marks memory for review |
| Trash2 (12px) | Delete | Deletes the memory |

All icon buttons: `background: none, border: none, color: textFaint, cursor: pointer, display: flex, padding: 3px`.

### Data Query 

```ts
const { data: memories } = useQuery({
  queryKey: ["persona-memories", personaId, memoryFilter],
  queryFn: async () => {
    const supabase = createClient();
    let query = supabase
      .from("persona_memories")
      .select(`
        *,
        source_chat:chats(id, title)
      `)
      .eq("persona_id", personaId)
      .order("created_at", { ascending: false });

    if (memoryFilter !== "all") {
      query = query.eq("type", memoryFilter);
    }

    const { data } = await query;
    return data ?? [];
  },
});
```

### Memory Edit Flow 

```ts
async function updateMemory(id: string, content: string) {
  const supabase = createClient();
  return supabase.from("persona_memories")
    .update({ content, updated_at: new Date().toISOString() })
    .eq("id", id);
}
```

---

## 9.8 Skills Tab 

The Skills tab shows the persona's current capabilities and capacity.

### Skill Capacity Bar 

At the top of the tab:

```ts
{
  marginBottom: 24,
}
```

- Label: `{current} of {ceiling} slots used` (12px, weight 300, `textMuted`, marginBottom 8px)  
- Progress bar (full width, height 4px, borderRadius 2px):  
  - Track: `surfaceAlt`  
  - Fill: `accent` color, width \= `(current / ceiling) * 100%`

### Skills List 

Each skill row:

```ts
{
  padding: "14px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
}
```

**Left side:**

- Zap icon (14px, `textFaint`)  
- Skill name: 14px, weight 400, `text`  
- (gap 10px between icon and name)

**Right side:**

- Duration label: "Permanent" or "Temporary (expires {date})" — 11px, weight 300, `textFaint`

### Level Indicator (v2) 

In v2, each skill will show a level indicator (1–5 dots or a mini progress bar). In v1, level is stored but not visually displayed in the list.

### Add Skill 

Below the skills list, an "Add Skill" button:

```ts
{
  marginTop: 12,
  display: "flex",
  alignItems: "center",
  gap: 4,
  background: "none",
  border: `1px dashed ${theme.border}`,
  borderRadius: 6,
  padding: "6px 12px",
  fontSize: 11,
  color: theme.textMuted,
  cursor: "pointer",
}
```

Plus icon (10px) \+ "Add Skill". Clicking opens an inline form:

- Skill name input (13px, placeholder "Skill name")  
- Category dropdown: general, design, writing, code, research  
- "Add" button (accent) \+ "Cancel" (text link)

### Skill Ceiling Enforcement 

When the user tries to add a skill beyond the ceiling, show:

```
This persona has reached its skill ceiling ({ceiling} skills). Increase the ceiling in the Boundaries tab or remove an existing skill.
```

(12px, weight 350, `textMuted`, padding 16px, `surfaceAlt` background, borderRadius 8\)

### Learning Consent Flow 

When `can_learn` is true and the persona detects a new skill opportunity during a conversation, the following flow occurs:

1. **Cipher identifies skill opportunity:** During post-response analysis, Cipher detects the persona used a capability not in its skill list.  
     
2. **Notification created:** A notification of type "persona" is created: `{persona_name} wants to learn: {skill_name}`.  
     
3. **Learning request card:** In the Skills tab, a pending request card appears at the top:

```ts
{
  padding: "14px 16px",
  background: theme.surfaceAlt,
  borderRadius: 10,
  border: `1px solid ${theme.borderSubtle}`,
  marginBottom: 16,
}
```

- Header: Sparkles icon (12px) \+ "Learning Request" (12px, weight 450\)  
- Description: `{persona_name} demonstrated {skill_name} in "{chat_title}". Add this as a skill?` (12px, weight 350, `textSec`, line-height 1.5)  
- Buttons: "Approve" (accent bg, 11px) \+ "Dismiss" (bordered, 11px)  
    
4. **On approve:** Skill is added to `persona_skills` with `level: 1` and `learnable: true`. Activity log entry created.  
     
5. **On dismiss:** Request is cleared. No skill added.

---

## 9.9 Health Tab 

The Health tab provides insight into the persona's operational state. This is how users monitor whether a persona is functioning well or needs intervention.

### Health Metrics Grid 

2-column grid (1-column on mobile), gap 24px, marginBottom 40px:

```ts
// Each metric
{
  // Value
  fontSize: 28,
  fontWeight: 200,
  color: theme.text,
  letterSpacing: "-0.03em",
}
// Label
{
  fontSize: 12,
  color: theme.textMuted,
  marginTop: 4,
  fontWeight: 300,
}
```

4 metrics:

| Metric | Value | Source |
| :---- | :---- | :---- |
| Memory Stability | Percentage | `(non-contradicted memories / total active memories) * 100` |
| Skill Stability | Percentage | `(recently-used skills / total skills) * 100` |
| Last Sleep | Time ago | Time since last status change to "sleeping" |
| Drift | Low/Medium/High | Cipher analysis of behavioral consistency |

**Drift calculation:** Cipher periodically compares the persona's recent response patterns against its identity definition. If responses are diverging from the persona's voice/purpose, drift is marked as "medium" or "high".

### Status Explanations 

Below the grid, a brief explanation of the current status:

| Status | Description |
| :---- | :---- |
| active | "This persona is actively responding to messages." |
| idle | "This persona hasn't been addressed in the last 2 hours." |
| sleeping | "This persona has been manually disabled or is in recovery." |

### Recovery Actions 

Header: "RECOVERY" (11px, weight 500, `textMuted`, uppercase, letter-spacing 0.1em, marginBottom 16px).

Two recovery action rows:

```ts
{
  padding: "14px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  cursor: "pointer",
}
```

| Action | Description | Effect |
| :---- | :---- | :---- |
| Soft Reset | "Clear moods and confusion" | Resets `mood` to "Neutral", clears any temporary state flags in `settings` JSONB, creates a health snapshot |
| Minimize Experience | "Reduce memory weight" | Lowers the confidence of all "weak" memories to inactive, removes expired temporary skills, creates a health snapshot |

Each row shows the action title (14px, weight 400, `text`) and description (12px, weight 300, `textMuted`, marginTop 2px).

A confirmation dialog should appear before executing recovery actions:

```
Are you sure you want to {action}? This will {specific effect}. This cannot be undone.
```

### Health History (v2) 

In v2, the Health tab will include a timeline visualization showing mood changes, drift events, and health snapshots over time. The `persona_health_snapshots` table captures this data for future visualization.

---

## 9.10 Create Persona Modal 

The creation flow is a 4-step wizard accessible from the "+New" button on the People list or via the Command Palette (⌘⇧P).

### Modal Container 

```ts
// Backdrop
{
  position: "fixed",
  inset: 0,
  background: "rgba(0,0,0,0.4)",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  zIndex: 50,
}

// Modal
{
  background: theme.surface,
  borderRadius: 16,
  padding: 32,
  width: mobile ? "90%" : 480,
  maxHeight: "80vh",
  overflowY: "auto",
  boxShadow: "0 20px 60px rgba(0,0,0,0.2)",
}
```

### Header 

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  marginBottom: 24,
}
```

- Title: "Create Persona" (18px, weight 400\)  
- Close button: X icon (18px, `textMuted`)

### Step Indicator 

4 steps with a progress bar for each:

```ts
{
  display: "flex",
  gap: 4,
  marginBottom: 28,
}
```

Each step (flex: 1):

```ts
{
  display: "flex",
  flexDirection: "column",
  alignItems: "center",
  gap: 4,
}
```

- Progress bar: full width, height 3px, borderRadius 2px. Accent color if `stepIndex <= currentStep`, `surfaceAlt` otherwise.  
- Label: 10px. `text` \+ weight 500 if current step, `textFaint` \+ weight 300 if future.

Steps: "Name & Role", "Purpose", "Boundaries", "Review"

### Step 0: Name & Role 

Two input fields:

**Name:**

- Label: "Name" (12px, weight 400, `textMuted`, block, marginBottom 6px)  
- Input: placeholder "e.g. Alex" (14px, `surfaceAlt` bg, border, borderRadius 8, padding 10px 14px, full width)  
- marginBottom 20px

**Role:**

- Label: "Role" (same style)  
- Input: placeholder "e.g. Marketing Strategist" (same style)

### Step 1: Purpose 

Single textarea:

- Label: "Purpose" (12px, weight 400, `textMuted`, block, marginBottom 6px)  
- Textarea: placeholder "Describe what this Persona will do..." (14px, 4 rows, same styling as inputs, resize vertical)

### Step 2: Boundaries 

Two textareas:

**Will Do:**

- Label: "What should this Persona do?" (12px, weight 400, `textMuted`)  
- Textarea: placeholder "e.g. Research market trends, write reports..." (13px, 3 rows)  
- marginBottom 16px

**Won't Do:**

- Label: "What should this Persona avoid?" (same style)  
- Textarea: placeholder "e.g. Making financial commitments, contacting clients..." (13px, 3 rows)

### Step 3: Review 

Summary card:

```ts
{
  padding: 16,
  background: theme.surfaceAlt,
  borderRadius: 10,
}
```

- "Review" heading (14px, weight 450, `text`, marginBottom 12px)  
- Description: "Your new Persona will start with no memories or skills. They'll learn from conversations and build knowledge over time. You can always adjust their identity, boundaries, and skills later." (13px, weight 350, `textSec`, line-height 1.6)

In v2, this step will show a summary of all entered values and allow final editing before creation.

### Navigation Buttons 

```ts
{
  display: "flex",
  justifyContent: "space-between",
  marginTop: 28,
}
```

**Back/Cancel button:**

- Step 0: "Cancel" — closes modal  
- Steps 1–3: "Back" — goes to previous step  
- `border: 1px solid theme.border, borderRadius: 8, padding: 8px 20px, fontSize: 13, color: textSec`

**Next/Create button:**

- Steps 0–2: "Next" — validates current step and advances  
- Step 3: "Create" — submits the persona  
- `background: accent, color: accentText, border: none, borderRadius: 8, padding: 8px 24px, fontSize: 13, fontWeight: 450`

### Validation 

| Step | Validation |
| :---- | :---- |
| 0 | Name is required (minimum 1 character). Role is optional but recommended. |
| 1 | Purpose is optional. |
| 2 | Both fields are optional (boundaries can be added later). |
| 3 | No validation needed. |

If validation fails, the required input gets a red border and a helper text appears: "Name is required" (11px, \#ef4444, marginTop 4px).

### Create Submission 

On "Create" click:

```ts
async function createPersona(data: CreatePersonaData) {
  const supabase = createClient();

  // 1. Insert persona
  const { data: persona, error } = await supabase
    .from("personas")
    .insert({
      user_id: userId,
      name: data.name,
      role: data.role,
      purpose: data.purpose,
      voice: "",              // Set later or derived from purpose
      status: "active",
      mood: "Neutral",
    })
    .select()
    .single();

  if (error) throw error;

  // 2. Insert boundaries (if provided)
  const boundaries: PersonaBoundary[] = [];
  if (data.willDo) {
    // Split by newlines or commas, create individual entries
    data.willDo.split(/[\n,]/).filter(Boolean).forEach(desc => {
      boundaries.push({
        persona_id: persona.id,
        type: "will_do",
        description: desc.trim(),
      });
    });
  }
  if (data.wontDo) {
    data.wontDo.split(/[\n,]/).filter(Boolean).forEach(desc => {
      boundaries.push({
        persona_id: persona.id,
        type: "wont_do",
        description: desc.trim(),
      });
    });
  }
  if (boundaries.length > 0) {
    await supabase.from("persona_boundaries").insert(boundaries);
  }

  // 3. Create activity log entry
  await supabase.from("activity_log").insert({
    user_id: userId,
    actor_type: "user",
    actor_name: "You",
    action: "created persona",
    entity_type: "persona",
    entity_id: persona.id,
    entity_name: persona.name,
  });

  return persona;
}
```

After creation, the modal closes and the user is navigated to the new persona's detail view.

### Template Pre-fill 

When creating from a template, the wizard opens with all fields pre-filled from `persona_templates.config`. The user can modify any values before submitting. The step indicator shows all steps as visited so the user can jump between them.

---

## 9.11 Persona Status Lifecycle 

Persona status transitions follow a defined state machine:

```
     ┌──────── User enables ─────────┐
     │                                │
     ▼                                │
  ┌─────────┐    2hrs no use     ┌──────────┐
  │  active  │ ──────────────── > │   idle   │
  └─────────┘                    └──────────┘
     │    ▲                          │
     │    │  User sends message      │  User disables
     │    └──────────────────────────┘    or Soft Reset
     │                                        │
     │    User disables                       │
     └──────────────────┐                     │
                        ▼                     ▼
                   ┌──────────┐
                   │ sleeping │
                   └──────────┘
```

| Transition | Trigger |
| :---- | :---- |
| active → idle | No messages directed at this persona for 2 hours |
| idle → active | A message is directed at this persona |
| active → sleeping | User manually disables via overflow menu or Health tab |
| idle → sleeping | User manually disables |
| sleeping → active | User manually enables via overflow menu |

Status changes are written to `personas.status` and logged to `persona_health_snapshots` for the Health tab history.

### System Prompt Injection 

The persona's status, boundaries, and identity are injected into the system prompt when assembling context for an AI response (see Part 5, Section 5.8):

```
You are {name}, a {role}.

Purpose: {purpose}
Voice: {voice}

Boundaries:
- You WILL: {will_do items, comma separated}
- You will NOT: {wont_do items, comma separated}
- Escalation: {escalation rule}

Current mood: {mood}
Status: {status}
```

This ensures the AI model respects the persona's defined limits in every response.

---

*End of Part 9\. Proceed to Part 10: Teams Screen.*

# PART 10: TEAMS SCREEN (Agentic Teams) 

---

Agentic Teams allow users to group personas into coordinated units that work together autonomously toward defined goals. A Team is more than a persona roster — it is an orchestrated pipeline where personas take on hierarchical roles (Orchestrator, Manager, Worker), execute tasks in sequence or parallel, and report results back to the user. The Teams screen surfaces team creation, monitoring, run history, and member management.

Teams are a Pro-tier feature. The Teams screen is accessible from the sidebar under the "Advanced" section with a Pro badge. Non-Pro users see a tier-gated state.

---

## 10.1 Conceptual Architecture 

### Team Types 

| Type | Icon | Description | Lifecycle |
| :---- | :---- | :---- | :---- |
| Short-Term | Zap (10px) | A focused sprint to accomplish a specific goal. Automatically transitions to "completed" when all tasks are done. | draft → active → completed (or failed) |
| Long-Term | Clock (10px) | An ongoing initiative with no fixed end date. Runs continuously with periodic check-ins. The user manually pauses or completes it. | draft → active → paused → active → ... → completed |
| Recurring | RefreshCw (10px) | A repeating workflow that runs on a schedule (daily, weekly, monthly). Each execution creates a new run. | draft → active (runs on schedule) → paused → active → ... |

### Three-Tier Hierarchy 

Every team operates through a three-tier persona hierarchy. This is not merely organizational — it defines how information flows and who can communicate with whom during agentic execution.

```
         ┌──────────────────┐
         │   ORCHESTRATOR   │  ← Speaks to the user
         │   (1 persona)    │  ← Coordinates the overall plan
         └────────┬─────────┘
                  │
         ┌────────┴─────────┐
         │                  │
    ┌────▼────┐       ┌────▼────┐
    │ MANAGER │       │ MANAGER │  ← Coordinate sub-goals
    │(persona)│       │(persona)│  ← Report up to Orchestrator
    └────┬────┘       └────┬────┘
         │                  │
    ┌────┴────┐       ┌────┴────┐
    │ WORKER  │       │ WORKER  │  ← Execute individual tasks
    │(persona)│       │(persona)│  ← Report up to Manager
    └─────────┘       └─────────┘
```

**Communication rules:**

- Only the Orchestrator speaks directly to the user in the team's chat  
- Managers receive instructions from the Orchestrator and coordinate Workers beneath them  
- Workers execute single tasks and report results to their assigned Manager  
- The user can override the hierarchy at any time by directly @-mentioning a specific persona in the team chat  
- If no Managers are assigned, Workers report directly to the Orchestrator (flat hierarchy)

**Why this matters:** This prevents the chaos of all personas speaking to the user simultaneously. The Orchestrator synthesizes information from Managers and presents coherent updates. This mirrors how effective human teams operate — you talk to the lead, not every individual contributor.

---

## 10.2 Data Model 

### Teams Table 

```sql
CREATE TABLE public.teams (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  description TEXT DEFAULT '',
  type TEXT NOT NULL CHECK (type IN ('short_term', 'long_term', 'recurring')),
  status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'paused', 'completed', 'failed')),
  plan JSONB DEFAULT '{}'::jsonb,              -- Structured execution plan (see Plan JSON Structure below)
  schedule JSONB DEFAULT NULL,                  -- For recurring teams: cron-like config (see Schedule JSON Structure)
  progress INTEGER NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
  chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,  -- Dedicated team chat thread
  last_run_at TIMESTAMPTZ,
  archived_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.teams ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own teams"
  ON public.teams FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_teams_user ON public.teams(user_id, updated_at DESC);
CREATE INDEX idx_teams_status ON public.teams(user_id, status) WHERE archived_at IS NULL;

CREATE TRIGGER set_teams_updated_at
  BEFORE UPDATE ON public.teams FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### Team Members Table 

Each row represents one persona assigned to one team with a specific role and reporting line.

```sql
CREATE TABLE public.team_members (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id UUID NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE,
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  role TEXT NOT NULL CHECK (role IN ('orchestrator', 'manager', 'worker')),
  reports_to UUID REFERENCES public.team_members(id) ON DELETE SET NULL,
  assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  removed_at TIMESTAMPTZ,
  CONSTRAINT unique_active_member UNIQUE (team_id, persona_id) -- Only enforced when removed_at IS NULL via partial index below
);

ALTER TABLE public.team_members ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage members in own teams"
  ON public.team_members FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.teams
      WHERE teams.id = team_members.team_id
      AND teams.user_id = auth.uid()
    )
  );

CREATE UNIQUE INDEX idx_team_members_active ON public.team_members(team_id, persona_id)
  WHERE removed_at IS NULL;
CREATE INDEX idx_team_members_team ON public.team_members(team_id)
  WHERE removed_at IS NULL;
```

### Team Tasks Table 

Tasks within a team execution plan. These are distinct from Instance tasks (Part 7\) — team tasks represent discrete units of work within an agentic pipeline, executed by a persona during a run.

```sql
CREATE TABLE public.team_tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id UUID NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE,
  run_id UUID REFERENCES public.team_runs(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  description TEXT DEFAULT '',
  assignee_member_id UUID REFERENCES public.team_members(id) ON DELETE SET NULL,
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'failed', 'skipped')),
  sort_order INTEGER NOT NULL DEFAULT 0,
  depends_on UUID[] DEFAULT '{}',               -- Task IDs that must complete before this task starts
  result JSONB DEFAULT '{}'::jsonb,             -- Output from the task execution
  error_message TEXT,
  started_at TIMESTAMPTZ,
  completed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.team_tasks ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage tasks in own teams"
  ON public.team_tasks FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.teams
      WHERE teams.id = team_tasks.team_id
      AND teams.user_id = auth.uid()
    )
  );

CREATE INDEX idx_team_tasks_team ON public.team_tasks(team_id);
CREATE INDEX idx_team_tasks_run ON public.team_tasks(run_id) WHERE run_id IS NOT NULL;
CREATE INDEX idx_team_tasks_status ON public.team_tasks(team_id, status);
```

### Team Runs Table 

A "run" is a single execution of a team's plan. Short-term teams typically have one run. Recurring teams have many. Long-term teams accumulate runs as the user triggers check-ins.

```sql
CREATE TABLE public.team_runs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id UUID NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE,
  status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('queued', 'running', 'completed', 'failed', 'cancelled')),
  trigger_type TEXT NOT NULL DEFAULT 'manual' CHECK (trigger_type IN ('manual', 'scheduled', 'event')),
  tasks_total INTEGER NOT NULL DEFAULT 0,
  tasks_completed INTEGER NOT NULL DEFAULT 0,
  started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at TIMESTAMPTZ,
  result JSONB DEFAULT '{}'::jsonb,             -- Aggregated results from all tasks
  logs JSONB DEFAULT '[]'::jsonb,               -- Chronological execution log entries
  error_message TEXT,
  duration_ms INTEGER                            -- Total execution time in milliseconds
);

ALTER TABLE public.team_runs ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view runs in own teams"
  ON public.team_runs FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.teams
      WHERE teams.id = team_runs.team_id
      AND teams.user_id = auth.uid()
    )
  );

CREATE INDEX idx_team_runs_team ON public.team_runs(team_id, started_at DESC);
```

### Plan JSON Structure 

The `teams.plan` JSONB column defines the execution blueprint:

```ts
interface TeamPlan {
  goal: string;                           // Natural-language description of the team's objective
  steps: TeamPlanStep[];                  // Ordered list of execution steps
  success_criteria: string;               // How to determine if the goal was achieved
  max_iterations?: number;                // For recurring: max runs (null = unlimited)
}

interface TeamPlanStep {
  id: string;                             // UUID for referencing in depends_on
  title: string;
  description: string;
  assigned_role: "orchestrator" | "manager" | "worker";
  assigned_persona_id?: string;           // Specific persona, or null for role-based auto-assign
  depends_on: string[];                   // Step IDs that must complete first (DAG edges)
  expected_output: string;                // What this step should produce
  timeout_minutes?: number;               // Max execution time per step
}
```

### Schedule JSON Structure 

For recurring teams only (`teams.schedule`):

```ts
interface TeamSchedule {
  frequency: "daily" | "weekly" | "monthly";
  day_of_week?: number;                   // 0-6 for weekly (0 = Sunday)
  day_of_month?: number;                  // 1-31 for monthly
  hour: number;                           // 0-23, UTC
  minute: number;                         // 0-59
  timezone: string;                       // IANA timezone (e.g. "America/New_York")
  enabled: boolean;
}
```

### TypeScript Interfaces 

```ts
interface Team {
  id: string;
  user_id: string;
  name: string;
  description: string;
  type: "short_term" | "long_term" | "recurring";
  status: "draft" | "active" | "paused" | "completed" | "failed";
  plan: TeamPlan;
  schedule: TeamSchedule | null;
  progress: number;
  chat_id: string | null;
  last_run_at: string | null;
  archived_at: string | null;
  created_at: string;
  updated_at: string;
  // Aggregated from joins
  members?: TeamMemberWithPersona[];
  member_count?: number;
  tasks_total?: number;
  tasks_completed?: number;
  latest_run?: TeamRun;
}

interface TeamMember {
  id: string;
  team_id: string;
  persona_id: string;
  role: "orchestrator" | "manager" | "worker";
  reports_to: string | null;
  assigned_at: string;
  removed_at: string | null;
}

interface TeamMemberWithPersona extends TeamMember {
  persona: {
    id: string;
    name: string;
    role: string;
    status: string;
    avatar_config: Record<string, unknown>;
  };
}

interface TeamTask {
  id: string;
  team_id: string;
  run_id: string | null;
  title: string;
  description: string;
  assignee_member_id: string | null;
  assignee?: TeamMemberWithPersona;
  status: "pending" | "in_progress" | "completed" | "failed" | "skipped";
  sort_order: number;
  depends_on: string[];
  result: Record<string, unknown>;
  error_message: string | null;
  started_at: string | null;
  completed_at: string | null;
  created_at: string;
}

interface TeamRun {
  id: string;
  team_id: string;
  status: "queued" | "running" | "completed" | "failed" | "cancelled";
  trigger_type: "manual" | "scheduled" | "event";
  tasks_total: number;
  tasks_completed: number;
  started_at: string;
  completed_at: string | null;
  result: Record<string, unknown>;
  logs: TeamLogEntry[];
  error_message: string | null;
  duration_ms: number | null;
}

interface TeamLogEntry {
  timestamp: string;
  persona_id: string;
  persona_name: string;
  role: "orchestrator" | "manager" | "worker";
  task_id: string | null;
  action: string;                         // "started_task", "completed_task", "failed_task", "delegated", "synthesized"
  detail: string;                         // Human-readable log message
}
```

---

## 10.3 Teams List View 

### Container 

```ts
{
  padding: `${py}px ${px}px`,
  overflowY: "auto",
  height: "100%",
}

// Inner container
{
  maxWidth: 900,
  margin: "0 auto",
}
```

### Header 

```ts
{
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  marginBottom: 24,
}
```

**Left side:**

- Title: "Agentic Teams" (22px, weight 300, `text`, letter-spacing \-0.03em)  
- Subtitle: "Coordinate Personas to work together autonomously on complex goals." (13px, weight 300, `textMuted`, marginTop 4px)

**Right side:**

- "+New Team" button:

```ts
{
  background: theme.accent,
  color: theme.accentText,
  border: "none",
  borderRadius: 8,
  padding: "8px 18px",
  fontSize: 12,
  cursor: "pointer",
  fontWeight: 450,
  display: "flex",
  alignItems: "center",
  gap: 5,
}
```

Plus icon (13px) \+ "New Team" text. Opens the Create Team modal.

**Tier limit enforcement:** Pro tier supports up to 10 teams. Enterprise supports unlimited. When at the limit, the "+New Team" button shows a Lock icon (13px) instead of Plus and clicking opens the pricing modal instead of the create flow.

### Filter Chips 

```ts
{
  display: "flex",
  gap: 6,
  marginBottom: 20,
}
```

4 filter chips:

| ID | Label | Icon |
| :---- | :---- | :---- |
| `all` | All | (none) |
| `shortterm` | Short-Term | Zap (10px) |
| `longterm` | Long-Term | Clock (10px) |
| `recurring` | Recurring | RefreshCw (10px) |

Each chip:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 3,
  padding: "5px 12px",
  borderRadius: 20,
  fontSize: 11,
  cursor: "pointer",
  fontWeight: 400,
  border: "none",
  background: isActive ? theme.accent : theme.surfaceAlt,
  color: isActive ? theme.accentText : theme.textMuted,
}
```

Single-select filter. "All" is the default and shows every team regardless of type. Clicking a chip re-filters instantly (client-side, no re-fetch).

### Team Cards 

Teams are displayed as stacked cards in a vertical list:

```ts
// Card list wrapper
{
  display: "flex",
  flexDirection: "column",
  gap: 10,
}
```

Each team card:

```ts
{
  padding: "16px 20px",
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 12,
  cursor: "pointer",
}
```

Clicking anywhere on the card (except the Manage button) navigates to the Team Detail view.

**Top row** (marginBottom 10px):

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "flex-start",
}
```

Left side — title line (gap 8px, marginBottom 4px):

```ts
// Team name
{
  fontSize: 15,
  fontWeight: 450,
  color: theme.text,
}
```

- StatusDot (6px) — using the same Dot component from Part 3\. Colors: `active` \= green (`dot.active`), `paused` \= amber (`dot.idle`), `completed` \= blue (`accent`), `failed` \= red (\#ef4444), `draft` \= gray (`textFaint`).  
- Type badge:

```ts
{
  fontSize: 10,
  padding: "1px 7px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  color: theme.textMuted,
}
// Displays the team type: "Short-Term", "Long-Term", or "Recurring"
```

Left side — description line:

```ts
{
  fontSize: 12,
  color: theme.textMuted,
  fontWeight: 300,
}
```

Right side — "Manage" button:

```ts
{
  background: "none",
  border: `1px solid ${theme.border}`,
  borderRadius: 6,
  padding: "4px 10px",
  fontSize: 10,
  color: theme.textMuted,
  cursor: "pointer",
  flexShrink: 0,
}
```

Clicking "Manage" navigates to the Team Detail view (same as clicking the card).

**Stats row:**

```ts
{
  display: "flex",
  gap: mobile ? 16 : 28,
  fontSize: 12,
}
```

3 stat items, each following the standard inline stat pattern:

```ts
<span style={{ color: theme.textMuted }}>
  <span style={{ fontWeight: 450, color: theme.text }}>{value}</span> {label}
</span>
```

| Stat | Example | Source |
| :---- | :---- | :---- |
| Personas | "4 Personas" | Active team\_members count |
| Tasks done | "9/12 tasks done" | completed\_tasks / total\_tasks |
| Last run | "Last run 2 hrs ago" | Relative time from teams.last\_run\_at. `textFaint` color. Shows "Never run" if null. |

**Progress bar** (marginTop 10px):

```ts
// Track
{
  height: 3,
  borderRadius: 2,
  background: theme.surfaceAlt,
  overflow: "hidden",
}

// Fill
{
  height: "100%",
  width: `${(tasksCompleted / tasksTotal) * 100}%`,
  background: theme.dot.active,          // Green
  borderRadius: 2,
  transition: "width 0.3s ease",
}
```

If tasksTotal is 0, the progress bar is hidden entirely.

For completed teams, the fill takes the full width. For failed teams, the fill color is \#ef4444 (red).

**Persona avatar stack** (v2 enhancement): Below the stats row, a row of overlapping 20px persona avatars showing who's on the team. Maximum 5 shown, with a "+{n}" overflow count if more exist. This is a v2 visual — v1 uses the "4 Personas" text stat.

### Data Query 

```ts
const { data: teams, isLoading } = useQuery({
  queryKey: ["teams", teamsFilter],
  queryFn: async () => {
    const supabase = createClient();
    const { data, error } = await supabase
      .from("teams")
      .select(`
        *,
        members:team_members!inner(
          id,
          role,
          persona:personas(id, name, role, status)
        ),
        tasks:team_tasks(id, status),
        latest_run:team_runs(id, status, started_at, completed_at, tasks_total, tasks_completed)
      `)
      .is("archived_at", null)
      .order("updated_at", { ascending: false });

    if (error) throw error;

    // Post-process: aggregate member counts and task counts
    return (data ?? []).map(team => ({
      ...team,
      member_count: team.members?.filter(m => !m.removed_at)?.length ?? 0,
      tasks_total: team.tasks?.length ?? 0,
      tasks_completed: team.tasks?.filter(t => t.status === "completed")?.length ?? 0,
      latest_run: team.latest_run?.[0] ?? null,
    }));
  },
});

// Client-side filter
const filteredTeams = teamsFilter === "all"
  ? teams
  : teams?.filter(t => t.type === teamsFilter) ?? [];
```

### Empty State 

When no teams exist:

```ts
{
  padding: "60px 20px",
  textAlign: "center",
}
```

- Users icon (32px, `textFaint`, marginBottom 16px)  
- "Create your first team" (16px, weight 400, `text`)  
- "Teams coordinate multiple Personas to work together on complex goals autonomously." (13px, weight 300, `textMuted`, marginTop 8px, maxWidth 420px, margin: "0 auto")  
- "+New Team" button (accent bg, borderRadius 8, padding: "8px 20px", fontSize 12, fontWeight 450, marginTop 20px)

### Loading State 

While teams are loading, show 3 skeleton cards stacked vertically:

```ts
// Each skeleton card
{
  padding: "16px 20px",
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 12,
  animation: "pulse 1.5s ease-in-out infinite",
}
```

Each contains:

- Title placeholder: 160px × 14px bar (`surfaceAlt`, borderRadius 4\)  
- Description placeholder: 260px × 10px bar, marginTop 8px  
- Stats placeholder: 200px × 10px bar, marginTop 16px  
- Progress placeholder: full-width × 3px bar, marginTop 10px

---

## 10.4 Executive Teams Teaser 

Below the team cards list, a teaser card for the Enterprise-tier "Executive Teams" feature:

```ts
{
  marginTop: 24,
  padding: "16px 20px",
  background: theme.surfaceAlt,
  borderRadius: 12,
  border: `1px dashed ${theme.border}`,
}
```

**Header row** (flex, alignItems center, gap 8px, marginBottom 4px):

- Layers icon (14px, `textMuted`)  
- "Executive Teams" (13px, weight 450, `text`)  
- Enterprise badge:

```ts
{
  fontSize: 8,
  padding: "1px 5px",
  borderRadius: 4,
  background: "#2e95f3",
  color: "#fff",
  fontWeight: 600,
  textTransform: "uppercase",
  letterSpacing: "0.03em",
}
```

**Description:** "High-level strategic teams that manage and coordinate other teams for complex multi-phase operations." (12px, weight 300, `textMuted`)

Executive Teams are a conceptual extension where a top-level team's Orchestrator can delegate sub-goals to entire other teams rather than individual personas. This creates a hierarchy of teams for enterprise-scale workflows. This is a future feature gated to an Enterprise tier.

The teaser is always visible (not hidden after dismissal). It serves as a persistent upgrade prompt.

---

## 10.5 Team Detail View 

Navigating into a team (by clicking a card or the "Manage" button) opens the detail view. This follows the same structural pattern as Instance Detail (Part 7\) and Persona Detail (Part 9): header → tab navigation → scrollable tab content.

### Container 

```ts
{
  display: "flex",
  flexDirection: "column",
  height: "100%",
  maxWidth: 900,
  margin: "0 auto",
  width: "100%",
}
```

### Header 

```ts
{
  padding: `${py}px ${px}px 0`,
  flexShrink: 0,
}
```

**Title row** (flex, alignItems flex-start, justifyContent space-between, marginBottom 24px):

Left side:

- Team name: 24px, weight 300, `text`, letter-spacing \-0.02em  
- Metadata row (marginTop 4px, display flex, alignItems center, gap 8px):  
  - Type badge (same styling as list view, 10px fontSize, `surfaceAlt` bg, borderRadius 8\)  
  - StatusDot (6px)  
  - Status text: 13px, weight 300, `textMuted`. Displays the status capitalized: "Active", "Paused", "Completed", "Draft", "Failed".  
  - Schedule indicator (recurring only): RefreshCw icon (10px, `textFaint`) \+ "Every {frequency}" (11px, weight 300, `textFaint`). E.g. "Every Monday at 9:00 AM".

Right side — action buttons (gap 6px, flexShrink 0):

- "Run Now" button (primary action):

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 4,
  background: theme.accent,
  color: theme.accentText,
  border: "none",
  borderRadius: 8,
  padding: "6px 14px",
  fontSize: 12,
  fontWeight: 450,
  cursor: "pointer",
}
```

Play icon (12px) \+ "Run Now". Triggers a manual run execution (creates a new team\_run, invokes the execution engine). Disabled when team status is "completed" or "draft" with no plan, or when a run is already "running".

When a run is in progress, the button changes to a spinner icon (Loader2, 12px, rotating animation) \+ "Running..." in `textMuted` color, bordered style, disabled.

- "Chat" button:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 4,
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  padding: "6px 14px",
  fontSize: 12,
  color: theme.textSec,
  background: "none",
  cursor: "pointer",
}
```

MessageSquare icon (12px) \+ "Chat". Opens the team's dedicated chat thread. If `chat_id` is null, creates a new chat first with all team personas as participants, then navigates.

- Overflow menu button: MoreHorizontal icon (16px, `textMuted`), bordered, borderRadius 8, padding 6px.

**Overflow menu dropdown** (absolute positioned, right-aligned, 180px min-width, same dropdown pattern as Part 9):

| Icon | Label | Action |
| :---- | :---- | :---- |
| Edit3 (12px) | Edit Team | Opens edit mode for name/description/plan |
| Pause/Play (12px) | Pause / Resume | Toggles status between active and paused. Label changes based on current status. |
| RefreshCw (12px) | Reset Progress | Resets all task statuses to "pending", sets progress to 0\. Requires confirmation. |
| Copy (12px) | Duplicate Team | Creates a new team with the same plan, members, and config but no run history |
| Archive (12px) | Archive | Sets `archived_at` to now(), removes from list |
| Trash2 (12px) | Delete | Destructive action, colored \#ef4444. Requires confirmation dialog: "Are you sure? This will permanently delete this team, all its run history, and task results. This cannot be undone." |

### Tab Navigation 

4 tabs below the header:

```ts
{
  display: "flex",
  gap: mobile ? 14 : 24,
  borderBottom: `1px solid ${theme.borderSubtle}`,
  overflowX: "auto",
}
```

Each tab:

```ts
{
  padding: "0 0 12px",
  fontSize: 13,
  fontWeight: isActive ? 500 : 350,
  color: isActive ? theme.text : theme.textMuted,
  background: "none",
  border: "none",
  borderBottom: isActive ? `2px solid ${theme.accent}` : "2px solid transparent",
  cursor: "pointer",
  whiteSpace: "nowrap",
  transition: "color 0.15s ease",
}
```

Tabs: **Overview**, **Plan**, **Members**, **History**

### Tab Content Container 

```ts
{
  flex: 1,
  overflowY: "auto",
  padding: `24px ${px}px ${py}px`,
}
```

---

## 10.6 Overview Tab 

The landing tab. Shows key metrics, current run status, recent activity, and quick stats.

### Quick Stats 

4 stat blocks in a horizontal row (gap 48px desktop, 24px mobile):

```ts
// Each stat block
// Number
{
  fontSize: 32,
  fontWeight: 200,
  color: theme.text,
  letterSpacing: "-0.04em",
  lineHeight: 1,
}
// Label
{
  fontSize: 12,
  color: theme.textMuted,
  marginTop: 6,
  fontWeight: 300,
}
```

| Stat | Value | Source |
| :---- | :---- | :---- |
| Tasks | `{completed}/{total}` | team\_tasks count by status |
| Personas | `{count}` | Active team\_members count |
| Runs | `{count}` | Total team\_runs count |
| Uptime | `{duration}` | Time since team status changed to "active". E.g. "12 days". Shows "—" for draft teams. |

### Current Run Status Card 

If a run is currently in progress (`latest_run.status === "running"`), display a prominent status card:

```ts
{
  padding: 20,
  background: theme.surfaceAlt,
  borderRadius: 12,
  marginBottom: 28,
}
```

**Header row** (flex, justifyContent space-between, alignItems center, marginBottom 12):

- Left: Loader2 icon (14px, `accent`, `animation: spin 1s linear infinite`) \+ "Run in Progress" (14px, weight 450, `text`)  
- Right: "Cancel" button (bordered, 10px, `textMuted`). Cancels the running execution, sets run status to "cancelled".

**Progress section:**

- Task counter: `{completed} of {total} tasks completed` (12px, weight 300, `textMuted`, marginBottom 8\)  
- Progress bar: full-width, 4px height, `surfaceAlt` track (darker shade), accent fill. Width \= `(tasks_completed / tasks_total) * 100%`.  
- Currently executing task: `Currently: {task_title}` (11px, weight 350, `textSec`, marginTop 8). Shows the task with status "in\_progress", or "Waiting for dependencies..." if all ready tasks are pending model responses.

**Live log preview** (marginTop 16, maxHeight 120px, overflowY auto):

Last 5 log entries from the current run. Each entry:

```ts
{
  padding: "4px 0",
  fontSize: 11,
  color: theme.textMuted,
  fontWeight: 300,
  display: "flex",
  gap: 8,
  alignItems: "baseline",
}
```

- Timestamp: 10px, weight 400, `textFaint`, minWidth 56px. Format: "HH:MM:SS".  
- Persona name: 10px, weight 450, `text`.  
- Action text: 11px, weight 300, `textMuted`. E.g. "Started: Research competitor pricing".

If no run is in progress and the team has run before, show a compact summary instead:

```ts
{
  padding: "14px 16px",
  background: theme.surfaceAlt,
  borderRadius: 10,
  marginBottom: 28,
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
}
```

- Left: "Last run {relative\_time}" (12px, weight 350, `textSec`) \+ status badge (completed/failed, 10px pill with appropriate color)  
- Right: "View Details" text link (11px, weight 400, `accent`, cursor pointer). Switches to the History tab.

If the team has never run, this section is omitted entirely.

### Goal & Success Criteria 

Displayed below the run status (or at the top if no run exists):

```ts
{
  marginBottom: 28,
}
```

**Section header:** "GOAL" (11px, weight 500, `textMuted`, uppercase, letter-spacing 0.1em, marginBottom 10px)

**Goal text:**

```ts
{
  fontSize: 14,
  fontWeight: 350,
  color: theme.text,
  lineHeight: 1.6,
}
```

Displays `plan.goal`. If empty, shows "No goal defined yet. Add one in the Plan tab." (13px, weight 300, `textFaint`, fontStyle italic).

**Success criteria** (marginTop 12px):

- Label: "Success criteria:" (12px, weight 400, `textMuted`)  
- Text: `plan.success_criteria` (12px, weight 300, `textSec`, lineHeight 1.5, marginTop 2px)

### Recent Activity 

**Section header:** "RECENT ACTIVITY" (11px, weight 500, `textMuted`, uppercase, letter-spacing 0.1em, marginBottom 16px, marginTop 32px)

The 5 most recent log entries from the latest run, or from the most recent completed run if none is active.

Each log entry:

```ts
{
  padding: "10px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  gap: 12,
  alignItems: "flex-start",
}
```

- 20px persona avatar  
- Content (flex 1):  
  - Name \+ role badge: "{persona\_name}" (12px, weight 450, `text`) \+ role badge (9px, weight 500, `textFaint`, uppercase, letter-spacing 0.05em, `surfaceAlt` bg, padding 1px 6px, borderRadius 4px, marginLeft 6px). Badge text: "ORCH", "MGR", or "WKR".  
  - Action text: task action description (12px, weight 300, `textSec`, marginTop 2px, lineHeight 1.5)  
- Timestamp: 10px, weight 300, `textFaint`, flexShrink 0

If no runs have ever occurred, show:

- "No activity yet. Run your team to see execution logs here." (13px, weight 300, `textFaint`, padding 20px 0\)

---

## 10.7 Plan Tab 

The Plan tab defines what the team will do when it runs. It shows the goal, steps, and their dependency structure.

### Goal Section 

```ts
{
  marginBottom: 28,
}
```

**Label:** "Goal" (12px, weight 400, `textMuted`, marginBottom 6px) **Value / Editor:**

In read mode: goal text (14px, weight 400, `text`, lineHeight 1.6).

In edit mode: textarea (14px, `surfaceAlt` bg, `border: 1px solid theme.border`, borderRadius 8, padding: "10px 14px", width: "100%", minHeight 60px, resize vertical). Save button (accent bg, 11px, weight 450\) \+ Cancel button (bordered, 11px), gap 6, marginTop 6px.

"Edit" button: bordered, 4px 10px padding, 11px, `textMuted`, flexShrink 0\. Positioned top-right of the section. Toggles between read/edit modes.

### Steps List 

**Section header** (flex, justifyContent space-between, alignItems center, marginBottom 16px):

- Left: "STEPS" (11px, weight 500, `textMuted`, uppercase, letter-spacing 0.1em)  
- Right: "Add Step" button (Plus icon 10px \+ "Add Step" 11px, bordered, padding 4px 10px)

Each step card:

```ts
{
  padding: "14px 16px",
  background: theme.surfaceAlt,
  borderRadius: 10,
  marginBottom: 10,
  border: `1px solid ${theme.borderSubtle}`,
}
```

**Step header row** (flex, justifyContent space-between, alignItems center, marginBottom 6px):

Left:

- Step number: "{n}." (13px, weight 450, `textFaint`, marginRight 6px). Auto-numbered based on sort\_order.  
- Step title: 13px, weight 450, `text`

Right (gap 4px):

- Role badge: 9px, weight 500, uppercase, letter-spacing 0.05em. Color: orchestrator \= `accent`, manager \= `dot.idle` (amber), worker \= `textMuted`. Pill: padding 1px 6px, borderRadius 4px, `surfaceAlt` bg (slightly darker shade).  
- Overflow: MoreHorizontal icon (14px, `textFaint`). Dropdown: Edit, Move Up, Move Down, Delete.

**Step description:** 12px, weight 300, `textSec`, lineHeight 1.5.

**Step metadata row** (marginTop 8px, display flex, gap 16px, fontSize 10):

- Assigned persona: 10px, weight 400, `textMuted`. Shows persona name if `assigned_persona_id` is set, otherwise "{Role} (auto-assign)".  
- Dependencies: 10px, weight 300, `textFaint`. "Depends on: Step 1, Step 3" or "No dependencies" if depends\_on is empty.  
- Expected output: 10px, weight 300, `textFaint`. Truncated to 60 chars.

**Dependency indicators:** Steps with dependencies show a small ArrowDown icon (8px, `textFaint`) above the card to visually indicate the flow direction.

### Add Step Inline Form 

When "Add Step" is clicked, an inline form appears at the bottom of the steps list:

```ts
{
  padding: "16px",
  background: theme.surface,
  borderRadius: 10,
  border: `1px dashed ${theme.border}`,
  marginBottom: 10,
}
```

Fields (flex column, gap 12px):

- Title input: "Step title" placeholder (13px, `surfaceAlt` bg, border, borderRadius 8, padding 8px 12px)  
- Description textarea: "What should this step accomplish?" (12px, 2 rows, same styling)  
- Assigned role: 3 radio buttons (Orchestrator / Manager / Worker), inline, 11px  
- Persona picker: Optional dropdown showing personas in that role. Label: "Assign to specific persona (optional)" (10px, `textFaint`).  
- Dependencies: Multi-select checkboxes of existing steps. "This step depends on:" label.  
- Expected output: "What should this step produce?" (12px, 1 row input)

Buttons: "Add" (accent bg, 11px, weight 450\) \+ "Cancel" (bordered, 11px), gap 6, marginTop 4px.

### Auto-Generate Plan 

Below the steps list:

```ts
{
  marginTop: 16,
  padding: "12px 16px",
  background: theme.surfaceAlt,
  borderRadius: 10,
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
}
```

- Left: Sparkles icon (12px, `accent`) \+ "AI can generate a plan from your goal" (12px, weight 350, `textSec`)  
- Right: "Auto-generate" button (bordered, 10px, weight 450\)

Clicking invokes Cipher with the team's goal, description, and member list. Cipher returns a structured plan with steps, dependencies, and role assignments. The generated plan replaces any existing steps (with a confirmation dialog if steps already exist: "This will replace your current {n} steps with an AI-generated plan. Continue?").

### Empty Plan State 

When no steps have been defined:

```ts
{
  padding: "40px 20px",
  textAlign: "center",
}
```

- GitBranch icon (28px, `textFaint`, marginBottom 12px)  
- "Define your execution plan" (14px, weight 400, `text`)  
- "Add steps manually or let AI generate a plan from your goal." (12px, weight 300, `textMuted`, marginTop 6px, maxWidth 360px, margin auto)  
- Two buttons side by side (gap 8px, marginTop 16px):  
  - "Add First Step" (bordered, 11px)  
  - "Auto-generate Plan" (accent bg, 11px, Sparkles icon 10px prefix)

---

## 10.8 Members Tab 

Displays the team's persona roster organized by their hierarchical roles.

### Hierarchy Visualization 

The member list is organized in three visual tiers:

**Tier section** (repeated for each tier that has members):

```ts
{
  marginBottom: 24,
}
```

**Tier header** (flex, alignItems center, gap 8px, marginBottom 12px):

- Role label: "ORCHESTRATOR", "MANAGERS", or "WORKERS" (11px, weight 500, `textMuted`, uppercase, letter-spacing 0.1em)  
- Count badge: `{n}` (10px, weight 400, `textFaint`)

**Member cards** within each tier (flex column, gap 8px):

```ts
{
  padding: "12px 16px",
  background: theme.surfaceAlt,
  borderRadius: 10,
  display: "flex",
  alignItems: "center",
  gap: 12,
  cursor: "pointer",
}
```

- 32px persona avatar  
- Content (flex 1):  
  - Persona name: 13px, weight 450, `text`  
  - Persona role (from the persona's own role field, not the team role): 11px, weight 300, `textMuted`, marginTop 2px  
- Status dot: 6px, using persona's current status  
- Reports-to indicator (Managers and Workers only): ArrowUp icon (10px, `textFaint`) \+ "Reports to {name}" (10px, weight 300, `textFaint`)  
- Remove button: X icon (12px, `textFaint`), background none, border none, padding 4px, cursor pointer. Shows on hover only (`opacity: 0, transition: opacity 0.15s`, parent `:hover` sets `opacity: 1`).

Clicking a member card navigates to the Persona Detail view (Part 9).

### Reporting Lines Visual 

Between the Orchestrator tier and the Managers tier, and between the Managers tier and the Workers tier, draw a visual connector:

```ts
{
  height: 20,
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
}
```

A vertical line segment: 1px width, 20px height, `borderSubtle` color. Flanked by small ChevronDown icons (8px, `textFaint`) above and below.

If no Managers exist, Workers connect directly to the Orchestrator. The Managers section header shows "(none)" and an "Add Manager" prompt.

### Add Member 

Below the Workers section:

```ts
{
  marginTop: 12,
  display: "flex",
  gap: 8,
}
```

- "Add Persona" button: dashed border, 6px 12px padding, borderRadius 8, 11px, `textMuted`. Plus icon (10px) \+ "Add Persona".

Clicking opens a dropdown showing available personas (not already assigned to this team):

```ts
{
  position: "absolute",
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  padding: 6,
  minWidth: 260,
  maxHeight: 240,
  overflowY: "auto",
  boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
  zIndex: 40,
}
```

Each persona option:

```ts
{
  padding: "8px 10px",
  borderRadius: 7,
  cursor: "pointer",
  display: "flex",
  alignItems: "center",
  gap: 10,
}
```

- 24px avatar \+ name (12px, weight 400\) \+ role (10px, `textFaint`)  
- Hover: `surfaceAlt` bg

After selecting a persona, a secondary prompt appears asking for the role assignment:

```ts
// Role picker popover
{
  display: "flex",
  gap: 4,
  padding: "6px 10px",
  background: theme.surfaceAlt,
  borderRadius: 8,
}
```

3 role chips: "Orchestrator", "Manager", "Worker". Each: 10px, padding 3px 8px, borderRadius 6\. Selected: accent bg, accentText. Unselected: bordered, textMuted.

If an Orchestrator already exists and the user selects "Orchestrator", show a warning: "This team already has an Orchestrator ({name}). Only one Orchestrator is allowed per team." (10px, \#ef4444, marginTop 4px).

### Member Limits 

| Tier | Max Personas per Team |
| :---- | :---- |
| Pro | 5 |
| Enterprise | 15 |

When at the limit, the "Add Persona" button shows a lock icon and displays: "Maximum {limit} personas per team." (10px, `textFaint`, marginTop 4px).

---

## 10.9 History Tab 

Shows the complete run history for the team.

### Run List 

Runs listed in reverse chronological order (most recent first):

```ts
// Run card
{
  padding: "14px 16px",
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 10,
  marginBottom: 8,
  cursor: "pointer",
}
```

**Header row** (flex, justifyContent space-between, alignItems center):

Left:

- Run label: "Run \#{index}" (13px, weight 450, `text`). Index is the chronological run number (1-based).  
- Status badge (marginLeft 8px):

```ts
{
  fontSize: 9,
  padding: "2px 7px",
  borderRadius: 4,
  fontWeight: 500,
  textTransform: "uppercase",
  letterSpacing: "0.03em",
}
```

Colors by status:

- `completed`: background `rgba(34,197,94,0.1)`, color `dot.active`  
- `failed`: background `rgba(239,68,68,0.1)`, color `#ef4444`  
- `cancelled`: background `rgba(156,163,175,0.1)`, color `textFaint`  
- `running`: background `rgba(46,149,243,0.1)`, color `accent`

Right:

- Duration: 11px, weight 300, `textFaint`. Format: "2m 34s" or "—" if null.  
- Trigger badge: 10px, weight 300, `textFaint`. "Manual" / "Scheduled" / "Event".

**Details row** (marginTop 6px, flex, gap 20px, fontSize 11):

- Task count: `{completed}/{total} tasks` (weight 350, `textMuted`)  
- Started: relative time (weight 300, `textFaint`)  
- Error indicator (failed runs only): AlertTriangle icon (10px, \#ef4444) \+ truncated error message (11px, weight 300, \#ef4444, maxWidth 300px, overflow ellipsis)

Clicking a run card expands it inline to show the full execution log.

### Expanded Run Log 

When a run card is clicked, the card expands to show the full log:

```ts
{
  marginTop: 12,
  padding: "12px 14px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  maxHeight: 300,
  overflowY: "auto",
}
```

Each log entry:

```ts
{
  padding: "6px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  gap: 10,
  alignItems: "baseline",
  fontSize: 11,
}
```

- Timestamp: "HH:MM:SS" (10px, weight 400, `textFaint`, minWidth 56px, fontFamily monospace)  
- Persona name: 11px, weight 450, `text`  
- Role badge: 8px, weight 500, `textFaint`, uppercase  
- Action: 11px, weight 300, `textSec`. Displays the `detail` field.

**Action row at the bottom of expanded log** (marginTop 10px, display flex, gap 8px):

- "Retry" button (failed runs only): RefreshCw icon (10px) \+ "Retry" (10px, bordered). Creates a new run with the same plan.  
- "View Full Output" button: ExternalLink icon (10px) \+ "Full Output" (10px, bordered). Opens a modal with the complete `result` JSON rendered as formatted text.  
- "Collapse" text link: ChevronUp icon (10px, `textFaint`) \+ "Collapse" (10px, `textFaint`).

### Empty History 

When no runs exist:

- "No runs yet. Click 'Run Now' to execute your team's plan." (13px, weight 300, `textFaint`, padding 24px 0, textAlign center)

---

## 10.10 Create Team Modal 

Triggered by the "+New Team" button on the list view.

### Modal Container 

Same modal pattern as Persona creation (Part 9):

```ts
// Backdrop
{
  position: "fixed",
  inset: 0,
  background: "rgba(0,0,0,0.4)",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  zIndex: 50,
}

// Modal
{
  background: theme.surface,
  borderRadius: 16,
  padding: 32,
  width: mobile ? "90%" : 520,
  maxHeight: "85vh",
  overflowY: "auto",
  boxShadow: "0 20px 60px rgba(0,0,0,0.2)",
}
```

### Header 

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  marginBottom: 24,
}
```

- Title: "Create a Team" (18px, weight 400\)  
- Close button: X icon (18px, `textMuted`, cursor pointer)

### Step Indicator 

5 steps (same step indicator pattern as Part 9, Section 9.9):

```ts
{
  display: "flex",
  gap: 4,
  marginBottom: 28,
}
```

Each step (flex 1, flex column, alignItems center, gap 4):

- Progress bar: full width, 3px height, borderRadius 2, accent color if `stepIndex <= currentStep` else `surfaceAlt`  
- Label: step name (10px, weight 500 if current, weight 300 \+ `textFaint` if future)

Steps: "Basics", "Members", "Plan", "Schedule", "Review"

### Step 0: Basics 

**Name input:**

```ts
// Label
{
  fontSize: 12,
  fontWeight: 400,
  color: theme.textMuted,
  display: "block",
  marginBottom: 6,
}
// Input
{
  fontSize: 14,
  background: theme.surfaceAlt,
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  padding: "10px 14px",
  width: "100%",
  color: theme.text,
  outline: "none",
}
```

Placeholder: "e.g. Content Pipeline". marginBottom 20px.

**Description textarea:** Same label/input pattern. Placeholder: "What should this team accomplish?". 3 rows, resize vertical. marginBottom 20px.

**Type selector:** Three radio cards (flex, gap 10px):

```ts
// Each type card
{
  flex: 1,
  padding: "12px 14px",
  background: isSelected ? theme.surfaceAlt : "none",
  border: isSelected ? `2px solid ${theme.accent}` : `1px solid ${theme.borderSubtle}`,
  borderRadius: 10,
  cursor: "pointer",
  textAlign: "center",
}
```

Each card:

- Icon: Zap / Clock / RefreshCw (16px, accent if selected, `textFaint` if not, marginBottom 6px)  
- Type name: 12px, weight 450, `text` if selected, `textMuted` if not  
- Description: 10px, weight 300, `textFaint`, marginTop 2px. "Focused sprint" / "Ongoing initiative" / "Repeating workflow"

Default selection: none. User must pick one.

### Step 1: Members 

**Instructions:** "Assign personas to your team. Every team needs exactly one Orchestrator." (12px, weight 300, `textMuted`, marginBottom 16px)

**Persona roster builder:**

Each assigned persona is shown as a row:

```ts
{
  padding: "10px 12px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  display: "flex",
  alignItems: "center",
  gap: 10,
  marginBottom: 6,
}
```

- 24px avatar  
- Persona name: 12px, weight 400, `text` (flex 1\)  
- Role dropdown: `<select>` styled as a pill (10px, `surfaceAlt` bg with slightly darker shade, border, borderRadius 6, padding 3px 8px). Options: Orchestrator, Manager, Worker.  
- Remove button: X icon (12px, `textFaint`)

**Add persona button:** dashed border, 8px 12px padding, borderRadius 8, 11px, `textMuted`, Plus icon 10px \+ "Add Persona". Opens a dropdown of available personas (same pattern as the Members tab add flow).

**Validation indicators** (below the roster, marginTop 10px):

```ts
// Validation message
{
  fontSize: 11,
  display: "flex",
  alignItems: "center",
  gap: 4,
  marginBottom: 4,
}
```

- ✓ "1 Orchestrator assigned" (green, `dot.active`) — or ✗ "Orchestrator required" (red, \#ef4444)  
- ✓ "At least 1 Worker assigned" (green) — or ✗ "At least 1 Worker required" (red)  
- ℹ "Managers are optional" (`textFaint`)

### Step 2: Plan (Optional) 

**Goal textarea:** Label "Team Goal" \+ textarea placeholder "Describe what this team should accomplish..." (13px, 3 rows). marginBottom 16px.

**Success criteria input:** Label "Success Criteria" \+ input placeholder "How will you know the goal is achieved?" (13px, 1 row). marginBottom 16px.

**Steps section:** "Steps (optional)" label. Description: "You can add steps now or let the team figure it out during execution." (11px, weight 300, `textFaint`).

Step list: If any steps have been added, show them as compact rows (title \+ role badge \+ remove X). Otherwise show empty state.

"Add Step" button: Plus icon \+ "Add Step" (bordered, 11px). Opens inline mini-form: title input \+ role dropdown \+ "Add" button.

**Auto-generate option:**

```ts
{
  marginTop: 16,
  padding: "10px 14px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
}
```

- Sparkles icon (12px, `accent`) \+ "Let AI generate a plan from your goal" (11px, weight 350, `textSec`)  
- "Generate" button (bordered, 10px, weight 450\)

Requires the goal field to be non-empty. If empty, the "Generate" button is disabled and shows a tooltip: "Add a goal first."

### Step 3: Schedule (Recurring Only) 

Only shown if type \= "recurring". If type ≠ "recurring", this step is automatically skipped in the step indicator.

**Frequency selector** (3 radio buttons, inline, marginBottom 16px):

```ts
// Each frequency option
{
  padding: "6px 14px",
  borderRadius: 8,
  fontSize: 12,
  border: isSelected ? "none" : `1px solid ${theme.border}`,
  background: isSelected ? theme.accent : "none",
  color: isSelected ? theme.accentText : theme.textMuted,
  cursor: "pointer",
}
```

Options: "Daily", "Weekly", "Monthly".

**Day of week selector** (weekly only): 7 small circular buttons (30px × 30px, borderRadius 50%), labeled S M T W T F S. Selected: accent bg, accentText. Unselected: bordered, textMuted.

**Day of month selector** (monthly only): Number input 1–31. Label: "Day of month" (12px, weight 400, `textMuted`). Input: 60px width, centered text.

**Time picker:** Two inputs side by side (gap 8px). Hour (0–23, 60px, centered) \+ ":" separator (12px, `textFaint`) \+ Minute (0–59, 60px, centered). Label: "Time (24h)" above.

**Timezone dropdown:** Full-width select input. Pre-filled with the user's detected timezone. Label: "Timezone". Common options: America/New\_York, America/Chicago, America/Denver, America/Los\_Angeles, Europe/London, Europe/Paris, Asia/Tokyo, etc.

### Step 4: Review 

Summary card:

```ts
{
  padding: 16,
  background: theme.surfaceAlt,
  borderRadius: 10,
}
```

**Heading:** "Review" (14px, weight 450, marginBottom 12px)

**Summary fields** (each field: marginBottom 10px):

```ts
// Field label
{
  fontSize: 11,
  fontWeight: 400,
  color: theme.textMuted,
  textTransform: "uppercase",
  letterSpacing: "0.05em",
  marginBottom: 2,
}
// Field value
{
  fontSize: 13,
  fontWeight: 350,
  color: theme.text,
}
```

Fields:

- **Name**: team name  
- **Type**: team type with icon  
- **Members**: Persona names with role badges. E.g. "Sally (Orchestrator), Sam (Manager), Dev (Worker)"  
- **Goal**: plan goal (or "Not defined" in `textFaint`)  
- **Steps**: "{n} steps defined" or "No steps — team will plan during execution"  
- **Schedule** (recurring only): "Every {frequency} at {time} {timezone}"

**Review note:**

```ts
{
  fontSize: 12,
  fontWeight: 300,
  color: theme.textSec,
  lineHeight: 1.6,
  marginTop: 12,
  paddingTop: 12,
  borderTop: `1px solid ${theme.borderSubtle}`,
}
```

"Your team will start in draft status. Click 'Run Now' from the team detail view to begin execution, or set the status to active for scheduled runs."

### Navigation Buttons 

Same pattern as Persona create (Part 9, Section 9.9):

```ts
{
  display: "flex",
  justifyContent: "space-between",
  marginTop: 28,
}
```

- Back/Cancel button: Step 0: "Cancel" (closes modal). Steps 1–4: "Back" (previous step). `border: 1px solid theme.border, borderRadius: 8, padding: 8px 20px, fontSize: 13, color: textSec`.  
- Next/Create button: Steps 0–3: "Next" (validates then advances). Step 4: "Create Team" (submits). `background: accent, color: accentText, borderRadius: 8, padding: 8px 24px, fontSize: 13, fontWeight: 450`.

### Validation 

- Step 0: Name required (min 1 char). Type required. Failed: red border on input \+ "Name is required" helper text (11px, \#ef4444, marginTop 4px).  
- Step 1: Exactly 1 Orchestrator required. At least 1 Worker required. At least 2 total members required.  
- Step 2: All optional.  
- Step 3: Frequency required (if shown). Time required. Timezone required.

### Create Submission 

```ts
async function createTeam(data: CreateTeamInput) {
  const supabase = createClient();

  // 1. Insert team
  const { data: team, error } = await supabase
    .from("teams")
    .insert({
      user_id: userId,
      name: data.name,
      description: data.description,
      type: data.type,
      status: "draft",
      plan: data.plan ?? { goal: "", steps: [], success_criteria: "" },
      schedule: data.type === "recurring" ? data.schedule : null,
    })
    .select()
    .single();

  if (error) throw error;

  // 2. Insert team members
  const memberInserts = data.members.map(m => ({
    team_id: team.id,
    persona_id: m.persona_id,
    role: m.role,
  }));

  const { data: insertedMembers, error: membersError } = await supabase
    .from("team_members")
    .insert(memberInserts)
    .select();

  if (membersError) throw membersError;

  // 3. Set reports_to relationships
  // Find the orchestrator member ID
  const orchestratorMemberId = insertedMembers.find(m => m.role === "orchestrator")?.id;
  const managerMemberIds = insertedMembers.filter(m => m.role === "manager").map(m => m.id);

  for (const member of insertedMembers) {
    let reportsTo: string | null = null;

    if (member.role === "manager") {
      reportsTo = orchestratorMemberId ?? null;
    } else if (member.role === "worker") {
      // Workers report to the first manager, or directly to orchestrator if no managers
      reportsTo = managerMemberIds[0] ?? orchestratorMemberId ?? null;
    }
    // Orchestrator reports_to remains null

    if (reportsTo) {
      await supabase
        .from("team_members")
        .update({ reports_to: reportsTo })
        .eq("id", member.id);
    }
  }

  // 4. Create team tasks from plan steps (if any)
  if (data.plan?.steps?.length) {
    const taskInserts = data.plan.steps.map((step, idx) => ({
      team_id: team.id,
      title: step.title,
      description: step.description,
      sort_order: idx,
      assignee_member_id: step.assigned_persona_id
        ? insertedMembers.find(m => m.persona_id === step.assigned_persona_id)?.id ?? null
        : null,
      depends_on: step.depends_on,
    }));

    await supabase.from("team_tasks").insert(taskInserts);
  }

  // 5. Create dedicated team chat
  const { data: chat } = await supabase
    .from("chats")
    .insert({
      user_id: userId,
      title: `Team: ${team.name}`,
      chat_type: "team",
      metadata: { team_id: team.id },
    })
    .select()
    .single();

  if (chat) {
    // Link chat to team
    await supabase
      .from("teams")
      .update({ chat_id: chat.id })
      .eq("id", team.id);

    // Add all team personas as chat participants
    const participantInserts = insertedMembers.map(m => ({
      chat_id: chat.id,
      persona_id: m.persona_id,
    }));
    await supabase.from("chat_participants").insert(participantInserts);
  }

  // 6. Activity log
  await supabase.from("activity_log").insert({
    user_id: userId,
    actor_type: "user",
    actor_name: "You",
    action: "created team",
    entity_type: "team",
    entity_id: team.id,
    entity_name: team.name,
  });

  return team;
}
```

After creation, close the modal and navigate to the newly created team's detail view.

---

## 10.11 Team Status Lifecycle 

### State Machine 

```
                    ┌──────────┐
          ┌────────►│  draft   │
          │         └─────┬────┘
          │               │ User defines plan / runs manually
          │               ▼
          │         ┌──────────┐   User pauses    ┌──────────┐
          │    ┌───►│  active  │──────────────────►│  paused  │
          │    │    └─────┬────┘                   └─────┬────┘
          │    │          │                               │
          │    │          │ All tasks complete             │ User resumes
          │    │          ▼                               │
          │    │    ┌──────────┐                          │
          │    │    │completed │                          │
          │    │    └──────────┘                          │
          │    │                                         │
          │    └─────────────────────────────────────────┘
          │
          │    active ───► failed (critical execution error)
          │
          │    any ───► archived (user archives)
          │
          └─── Reset: completed/failed ───► draft (user resets)
```

**Transitions:**

| From | To | Trigger |
| :---- | :---- | :---- |
| draft | active | User clicks "Run Now" or sets status to active |
| active | paused | User clicks "Pause" from overflow menu |
| active | completed | All tasks in the current run complete (short-term) or user manually completes (long-term) |
| active | failed | Critical execution error (orchestrator fails, unrecoverable) |
| paused | active | User clicks "Resume" from overflow menu |
| completed / failed | draft | User clicks "Reset Progress" from overflow menu |
| any | archived | User clicks "Archive" from overflow menu. Sets `archived_at = now()`. |

Status changes update `teams.status` and create an activity\_log entry.

For recurring teams, "completed" means the schedule has been disabled — the team doesn't auto-complete. It stays active between scheduled runs.

---

## 10.12 Team Execution Engine 

The execution engine is a Supabase Edge Function (`team-execute`) that orchestrates the actual work of a team. This runs server-side and coordinates persona responses through the AI pipeline.

### Trigger 

The execution engine is invoked in three ways:

1. **Manual:** User clicks "Run Now" → client calls Edge Function with `{ team_id, trigger_type: "manual" }`  
2. **Scheduled:** pg\_cron checks every minute for due recurring teams → invokes Edge Function with `trigger_type: "scheduled"`  
3. **Event:** Future — triggered by external webhooks or activity in linked Instances

### Execution Flow 

```
User clicks "Run Now" (or schedule triggers)
        │
        ▼
┌───────────────────────┐
│  1. CREATE team_run    │  status: "running"
│     Count plan steps   │  tasks_total: N
│     Set team.status    │  = "active" (if draft)
│     Update last_run_at │
└──────────┬────────────┘
           │
           ▼
┌───────────────────────┐
│  2. INSTANTIATE tasks  │  Create team_tasks rows
│     from plan steps    │  for this run, all "pending"
│     Link run_id        │
└──────────┬────────────┘
           │
           ▼
┌───────────────────────┐
│  3. IDENTIFY ready     │  Tasks where all depends_on
│     tasks              │  tasks have status "completed"
│     (DAG traversal)    │  and own status = "pending"
└──────────┬────────────┘
           │
           ▼
┌───────────────────────┐
│  4. EXECUTE each       │  For each ready task:
│     ready task:        │
│     a. Find assigned   │  Resolve persona from member
│        persona         │
│     b. Build context   │  Team goal + task desc +
│        (see below)     │  prior task results
│     c. Call AI model   │  Via Cipher routing (Part 5)
│        with persona    │
│        identity        │
│     d. Save result     │  Store in team_tasks.result
│     e. Update status   │  "completed" or "failed"
│     f. Log entry       │  Append to team_runs.logs
└──────────┬────────────┘
           │
           ▼
┌───────────────────────┐
│  5. REPEAT steps 3-4   │  Loop until:
│     for newly ready    │  - All tasks completed, OR
│     tasks              │  - All remaining tasks are
│                        │    blocked by failures, OR
│                        │  - Global timeout reached
└──────────┬────────────┘
           │
           ▼
┌───────────────────────┐
│  6. ORCHESTRATOR       │  Orchestrator persona receives
│     SYNTHESIS          │  all task results and generates
│                        │  a final summary for the user
└──────────┬────────────┘
           │
           ▼
┌───────────────────────┐
│  7. FINALIZE           │
│  - Update team_run     │  status: "completed" or "failed"
│  - Calculate duration  │
│  - Update team.progress│
│  - Post summary to     │  the team's chat thread
│    team chat           │
│  - Send notification   │  to user
└───────────────────────┘
```

### Context Assembly for Team Tasks 

Each task execution builds a specialized context that includes the team structure and prior results:

```ts
function buildTeamTaskContext(
  team: Team,
  task: TeamTask,
  persona: Persona,
  member: TeamMember,
  completedTasks: TeamTask[],
): Message[] {
  // 1. System prompt: Persona identity (from Part 9) + team role overlay
  const systemPrompt = `
You are ${persona.name}, a ${persona.role}.
Purpose: ${persona.purpose}
Voice: ${persona.voice}

${buildBoundariesBlock(persona)}

TEAM CONTEXT:
You are the ${member.role.toUpperCase()} of the team "${team.name}".
${ROLE_INSTRUCTIONS[member.role]}

TEAM GOAL: ${team.plan.goal}
SUCCESS CRITERIA: ${team.plan.success_criteria}
`;

  // 2. Prior results from dependency tasks
  const dependencyResults = completedTasks
    .filter(t => task.depends_on.includes(t.id))
    .map(t => `[Result from "${t.title}"]:\n${JSON.stringify(t.result.output ?? t.result)}`)
    .join("\n\n");

  // 3. The actual task instruction
  const taskInstruction = `
YOUR CURRENT TASK: ${task.title}
${task.description}

${dependencyResults ? `PRIOR RESULTS (from tasks you depend on):\n${dependencyResults}` : ""}

Please complete this task. Be thorough but concise. Structure your output clearly.
`;

  return [
    { role: "system", content: systemPrompt },
    { role: "user", content: taskInstruction },
  ];
}
```

**Role-specific instruction constants:**

```ts
const ROLE_INSTRUCTIONS: Record<string, string> = {
  orchestrator:
    "You are the team lead. You coordinate the overall goal. " +
    "You synthesize results from Managers and Workers and present the final output to the user. " +
    "You are the only persona the user interacts with directly. " +
    "When synthesizing, highlight key findings, flag disagreements between team members, " +
    "and provide actionable recommendations.",
  manager:
    "You coordinate a sub-team. You receive instructions from the Orchestrator " +
    "and break them into worker tasks. You synthesize worker outputs " +
    "and report a coherent summary back to the Orchestrator. " +
    "Focus on quality control and consistency.",
  worker:
    "You execute a specific, focused task. Complete only what is assigned to you. " +
    "Report your results clearly and concisely. " +
    "If you encounter a blocker or need clarification, state it explicitly rather than guessing.",
};
```

### Error Handling 

| Error | Recovery |
| :---- | :---- |
| Task model call fails (API error, rate limit) | Retry once after 5-second delay with same model. If still fails, retry with fallback model (next tier down). If still fails, mark task as "failed", log error with details, continue with other tasks. |
| Task timeout (exceeds `timeout_minutes`) | Mark task as "failed" with error\_message "Execution timed out after {n} minutes". Continue processing other tasks. |
| All tasks in a dependency chain fail | Mark all downstream dependent tasks as "skipped" with error\_message "Skipped due to upstream task failure: {failed\_task\_title}". Log a warning. |
| Orchestrator synthesis fails | Mark run as "failed". Post error summary to team chat. Send notification: "Team {name} execution failed: Orchestrator could not synthesize results." |
| Global timeout (run exceeds 30 minutes) | Cancel all remaining "pending" tasks. Mark them as "skipped". Orchestrator synthesizes whatever partial results exist. Mark run as "completed" with a warning flag in result JSON. |

### Progress Tracking 

During execution, the Edge Function updates `team_runs.tasks_completed` after each task completes. The client polls for run status every 3 seconds while the detail view is open:

```ts
const { data: runStatus } = useQuery({
  queryKey: ["team-run-status", teamId],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("team_runs")
      .select("id, status, tasks_total, tasks_completed, logs")
      .eq("team_id", teamId)
      .order("started_at", { ascending: false })
      .limit(1)
      .single();
    return data;
  },
  refetchInterval: (data) => data?.status === "running" ? 3000 : false,
});
```

### Recurring Execution Scheduler 

For recurring teams, a Supabase scheduled function (via pg\_cron) checks every minute for teams that are due to run:

```sql
-- pg_cron job: runs every minute
SELECT cron.schedule(
  'check-recurring-teams',
  '* * * * *',
  $$
    SELECT net.http_post(
      url := current_setting('app.settings.supabase_url') || '/functions/v1/team-execute',
      headers := jsonb_build_object(
        'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'),
        'Content-Type', 'application/json'
      ),
      body := jsonb_build_object('team_id', t.id, 'trigger_type', 'scheduled')
    )
    FROM public.teams t
    WHERE t.type = 'recurring'
      AND t.status = 'active'
      AND t.archived_at IS NULL
      AND (t.schedule->>'enabled')::boolean = true
      AND (
        t.last_run_at IS NULL
        OR t.last_run_at < now() - make_interval(
          days := CASE
            WHEN t.schedule->>'frequency' = 'daily' THEN 1
            WHEN t.schedule->>'frequency' = 'weekly' THEN 7
            WHEN t.schedule->>'frequency' = 'monthly' THEN 30
          END
        )
      );
  $$
);
```

---

## 10.13 Tier Gating 

| Feature | Tier Required |
| :---- | :---- |
| View Teams screen | Pro |
| Create teams | Pro |
| Maximum teams | Pro: 10, Enterprise: Unlimited |
| Maximum personas per team | Pro: 5, Enterprise: 15 |
| Recurring schedules | Pro |
| Manual runs per day | Pro: 20, Enterprise: Unlimited |
| Run history retention | Pro: 90 days, Enterprise: Unlimited |
| Executive Teams | Enterprise (future) |

### Non-Pro Gated Screen 

When a non-Pro user navigates to the Teams screen (via sidebar), they see a gated view:

```ts
{
  padding: "80px 20px",
  textAlign: "center",
  maxWidth: 420,
  margin: "0 auto",
}
```

- Users icon (36px, `textFaint`, marginBottom 16px)  
- "Agentic Teams" (20px, weight 400, `text`)  
- "Coordinate multiple Personas to work together autonomously on complex goals. Available on the Pro plan." (13px, weight 300, `textMuted`, line-height 1.6, marginTop 12px)  
- "Upgrade to Pro" button (`background: accent, color: accentText, border: none, borderRadius: 8, padding: 10px 28px, fontSize: 13, fontWeight: 450, marginTop: 24px`). Opens pricing modal.

---

*End of Part 10\. Proceed to Part 11: Browser / Co-Browsing Workspace.*

# PART 11: BROWSER / CO-BROWSING WORKSPACE 

---

The Browser is a fully functional embedded web browsing experience where personas can observe, analyze, highlight, and extract content from web pages alongside the user. It is not a separate application — it is a workspace view within aiConnected that maintains full chat continuity. The user browses; the persona reads, comments, and acts.

This is one of aiConnected's most distinctive features. Where other AI tools require copy-pasting URLs or text, aiConnected's co-browsing lets personas see what you see and discuss it in real time. The architecture consists of three layers: a session manager for navigating browse history, a real web viewport that renders actual websites through a server-side proxy, and a flexible chat overlay system with five distinct view modes.

The Browser screen is accessible from the sidebar. Entering browse mode collapses the main sidebar to maximize viewport space.

---

## 11.1 Architecture Overview 

### Implementation Strategy 

The Browser has two implementation paths depending on the runtime environment:

**Web Version (v1 — standalone web app):**

- Server-side proxy (Supabase Edge Function) fetches target URLs, rewrites relative URLs to absolute, strips restrictive headers, and injects aiConnected's overlay script  
- Rendered in a sandboxed `<iframe>` with proxy-rewritten content  
- Limitations: some sites block proxy access (banking, auth-walled, heavy SPAs). Graceful fallback messaging required.  
- This is the v1 implementation.

**Desktop Version (v2 — Electron/Tauri):**

- Native `BrowserView` (Electron) or `WebView2` (Tauri) for full browser embedding  
- Full cookie/session support, no proxy needed  
- Extension-like DOM access for highlighting and extraction  
- No same-origin restrictions  
- Video/audio playback, file downloads, full SPA support

The PRD specifies both paths. v1 ships with the web proxy version. All UI components (session manager, tab strip, nav bar, chat panel, view switcher) are shared across both implementations — only the viewport rendering differs.

### Proxy Architecture 

```
User enters URL in nav bar
         │
         ▼
┌────────────────────────────┐
│  Client sends request       │
│  to Edge Function           │
│  /api/browser/proxy         │
│  ?url={encoded_target_url}  │
│  Authorization: Bearer JWT  │
└──────────┬─────────────────┘
           │
           ▼
┌────────────────────────────┐
│  Edge Function:             │
│  1. Verify JWT, rate limit  │
│  2. Validate target URL     │
│  3. Fetch target URL        │
│     (follow redirects,      │
│      max 5 hops)            │
│  4. Strip CSP, X-Frame-Opt  │
│  5. Rewrite relative URLs   │
│     to absolute             │
│  6. Inject <base> tag       │
│  7. Inject overlay.js       │
│     before </body>          │
│  8. Return modified HTML    │
└──────────┬─────────────────┘
           │
           ▼
┌────────────────────────────┐
│  <iframe> renders           │
│  proxied content            │
│  overlay.js enables:        │
│  - Readable text extraction │
│  - Heading/table discovery  │
│  - Highlight rendering      │
│  - Section selection        │
│  - postMessage to parent    │
│    frame for all comms      │
└────────────────────────────┘
```

### Edge Function: browser-proxy 

```ts
// supabase/functions/browser-proxy/index.ts
import { serve } from "https://deno.land/std/http/server.ts";
import { createClient } from "@supabase/supabase-js";

const BLOCKED_DOMAINS = ["localhost", "127.0.0.1", "0.0.0.0"];
const MAX_REDIRECTS = 5;
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB

serve(async (req) => {
  const url = new URL(req.url);
  const targetUrl = url.searchParams.get("url");

  if (!targetUrl) {
    return new Response(JSON.stringify({ error: "Missing url parameter" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  // 1. Auth check
  const authHeader = req.headers.get("Authorization");
  if (!authHeader) {
    return new Response("Unauthorized", { status: 401 });
  }
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: authHeader } } }
  );
  const { data: { user }, error: authError } = await supabase.auth.getUser();
  if (authError || !user) return new Response("Unauthorized", { status: 401 });

  // 2. Rate limit: 60 requests per minute per user
  // Implementation: check rate_limits table or use Deno KV

  // 3. Validate URL
  let parsedUrl: URL;
  try {
    parsedUrl = new URL(targetUrl);
    if (!["http:", "https:"].includes(parsedUrl.protocol)) {
      throw new Error("Invalid protocol");
    }
    if (BLOCKED_DOMAINS.includes(parsedUrl.hostname)) {
      throw new Error("Blocked domain");
    }
  } catch {
    return new Response(JSON.stringify({
      error: "Invalid URL",
      message: "Only HTTP and HTTPS URLs are supported.",
      fallback: true,
    }), { status: 400, headers: { "Content-Type": "application/json" } });
  }

  // 4. Fetch target
  try {
    const response = await fetch(targetUrl, {
      headers: {
        "User-Agent": "Mozilla/5.0 (compatible; aiConnectedBrowser/1.0; +https://aiconnected.com/bot)",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
      },
      redirect: "follow",
    });

    // Check content type
    const contentType = response.headers.get("Content-Type") || "";
    if (!contentType.includes("text/html") && !contentType.includes("xhtml")) {
      return new Response(JSON.stringify({
        error: "Not an HTML page",
        message: "This URL doesn't serve an HTML page.",
        fallback: true,
      }), { status: 415, headers: { "Content-Type": "application/json" } });
    }

    let html = await response.text();

    if (html.length > MAX_RESPONSE_SIZE) {
      html = html.substring(0, MAX_RESPONSE_SIZE);
    }

    // 5. Rewrite URLs to absolute
    const base = new URL(targetUrl);
    html = rewriteUrls(html, base);

    // 6. Inject <base> tag for any URLs we miss
    html = html.replace(
      /<head([^>]*)>/i,
      `<head$1><base href="${base.origin}${base.pathname}">`
    );

    // 7. Inject overlay script before </body>
    const overlayUrl = `${Deno.env.get("PUBLIC_URL")}/browser-overlay.js`;
    const overlayScript = `<script src="${overlayUrl}"></script>`;
    html = html.replace("</body>", `${overlayScript}</body>`);

    // 8. Build response with sanitized headers
    const headers = new Headers();
    headers.set("Content-Type", "text/html; charset=utf-8");
    headers.set("X-Frame-Options", "ALLOWALL");
    headers.set("Access-Control-Allow-Origin", "*");
    // Deliberately omit original CSP, X-Frame-Options, X-Content-Type-Options

    return new Response(html, { headers });
  } catch (error) {
    return new Response(JSON.stringify({
      error: "Failed to fetch page",
      message: error.message,
      fallback: true,
    }), { status: 502, headers: { "Content-Type": "application/json" } });
  }
});

function rewriteUrls(html: string, base: URL): string {
  // Rewrite src, href, action, srcset attributes to absolute URLs
  return html.replace(
    /(src|href|action|srcset)=["'](?!https?:\/\/|data:|blob:|#|javascript:|mailto:)(\/?)([^"']*?)["']/gi,
    (match, attr, slash, path) => {
      try {
        const absolute = slash
          ? `${base.origin}/${path}`
          : new URL(path, `${base.origin}${base.pathname}`).href;
        return `${attr}="${absolute}"`;
      } catch {
        return match; // Leave malformed URLs unchanged
      }
    }
  );
}
```

### Fallback Error View 

When the proxy cannot load a page (blocked, auth-required, non-HTML, network error), show this in place of the iframe content:

```ts
{
  display: "flex",
  flexDirection: "column",
  alignItems: "center",
  justifyContent: "center",
  height: "100%",
  padding: 40,
  textAlign: "center",
}
```

- Lock icon (32px, `textFaint`, marginBottom 16px)  
- "This page couldn't be loaded" (16px, weight 400, `text`)  
- Reason text (13px, weight 300, `textMuted`, marginTop 8px, maxWidth 360px). Dynamic based on error:  
  - 502: "The site didn't respond or blocks embedded access."  
  - 415: "This URL doesn't serve an HTML page."  
  - 400: "The URL format is invalid."  
  - 401: "This page requires authentication."  
  - Default: "Something went wrong loading this page."  
- "Open in new tab" button (`border: 1px solid theme.border, borderRadius: 8, padding: 6px 16px, fontSize: 12, color: theme.textSec`, ExternalLink icon 10px). Opens `window.open(url, '_blank')`.  
- "Try another URL" button (`background: accent, color: accentText, borderRadius: 8, padding: 6px 16px, fontSize: 12, fontWeight: 450`, marginTop 8px). Focuses the URL input in the nav bar.

---

## 11.2 Data Model 

### Browser Sessions Table 

A session represents one continuous browsing context. It has a persona assigned and optionally links to an instance. Sessions contain tabs.

```sql
CREATE TABLE public.browser_sessions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  instance_id UUID REFERENCES public.instances(id) ON DELETE SET NULL,
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,   -- Linked co-browse chat
  status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed')),
  last_url TEXT DEFAULT '',                    -- Most recently viewed URL (for session list preview)
  last_action TEXT DEFAULT '',                 -- Most recent action description (for session list)
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  closed_at TIMESTAMPTZ
);

ALTER TABLE public.browser_sessions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own browser sessions"
  ON public.browser_sessions FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_browser_sessions_user ON public.browser_sessions(user_id, created_at DESC);
CREATE INDEX idx_browser_sessions_active ON public.browser_sessions(user_id)
  WHERE status = 'active';
```

### Browser Tabs Table 

Each tab within a session. Tabs persist their own navigation history for back/forward and preserve scroll position.

```sql
CREATE TABLE public.browser_tabs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  session_id UUID NOT NULL REFERENCES public.browser_sessions(id) ON DELETE CASCADE,
  url TEXT NOT NULL,
  title TEXT DEFAULT '',
  favicon_url TEXT DEFAULT '',
  favicon_color TEXT DEFAULT '#999',          -- Generated background color for tab square
  position INTEGER NOT NULL DEFAULT 0,         -- Order in the tab strip
  active BOOLEAN NOT NULL DEFAULT false,       -- Only one tab active per session
  navigation_history TEXT[] DEFAULT '{}',       -- Ordered array of visited URLs for back/forward
  history_position INTEGER NOT NULL DEFAULT 0,  -- Current index in navigation_history
  scroll_position INTEGER NOT NULL DEFAULT 0,   -- Preserved vertical scroll offset in pixels
  opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  closed_at TIMESTAMPTZ
);

ALTER TABLE public.browser_tabs ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage tabs in own sessions"
  ON public.browser_tabs FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.browser_sessions
      WHERE browser_sessions.id = browser_tabs.session_id
      AND browser_sessions.user_id = auth.uid()
    )
  );

CREATE INDEX idx_browser_tabs_session ON public.browser_tabs(session_id)
  WHERE closed_at IS NULL;
```

### Browser History Table 

Global browse history across all sessions. Used in the History sub-tab.

```sql
CREATE TABLE public.browser_history (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  url TEXT NOT NULL,
  title TEXT DEFAULT '',
  persona_id UUID REFERENCES public.personas(id) ON DELETE SET NULL,
  persona_name TEXT DEFAULT '',                -- Denormalized for display without join
  extract_count INTEGER NOT NULL DEFAULT 0,    -- Number of extracts created from this URL
  visit_count INTEGER NOT NULL DEFAULT 1,
  last_visited_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.browser_history ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own browser history"
  ON public.browser_history FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_browser_history_user ON public.browser_history(user_id, last_visited_at DESC);
CREATE UNIQUE INDEX idx_browser_history_url ON public.browser_history(user_id, url);
```

On each page visit, upsert into browser\_history:

```sql
INSERT INTO browser_history (user_id, url, title, persona_id, persona_name, last_visited_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (user_id, url)
DO UPDATE SET
  title = EXCLUDED.title,
  visit_count = browser_history.visit_count + 1,
  last_visited_at = now();
```

### Browser Extracts Table 

Content extracted from web pages and optionally saved to an Instance.

```sql
CREATE TABLE public.browser_extracts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  session_id UUID REFERENCES public.browser_sessions(id) ON DELETE SET NULL,
  tab_id UUID REFERENCES public.browser_tabs(id) ON DELETE SET NULL,
  instance_id UUID REFERENCES public.instances(id) ON DELETE SET NULL,
  title TEXT NOT NULL,
  content TEXT NOT NULL,                          -- Extracted text, HTML table markup, or image URL
  source_url TEXT NOT NULL,
  extract_type TEXT NOT NULL CHECK (extract_type IN ('text', 'table', 'image', 'full_page')),
  metadata JSONB DEFAULT '{}'::jsonb,            -- { selector, dimensions, section_heading, word_count }
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.browser_extracts ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own extracts"
  ON public.browser_extracts FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_extracts_user ON public.browser_extracts(user_id, created_at DESC);
CREATE INDEX idx_extracts_instance ON public.browser_extracts(instance_id)
  WHERE instance_id IS NOT NULL;
```

### Browser Highlights Table 

Highlights are visual annotations on page content created by the user or persona.

```sql
CREATE TABLE public.browser_highlights (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tab_id UUID NOT NULL REFERENCES public.browser_tabs(id) ON DELETE CASCADE,
  persona_id UUID REFERENCES public.personas(id) ON DELETE SET NULL,
  created_by TEXT NOT NULL DEFAULT 'user' CHECK (created_by IN ('user', 'persona')),
  selector TEXT NOT NULL,                        -- CSS selector or XPath targeting the element
  content_preview TEXT DEFAULT '',               -- First ~200 chars of highlighted content
  note TEXT DEFAULT '',                          -- Optional note about the highlight
  color TEXT DEFAULT '#2e95f3',                  -- Highlight border color
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_highlights_tab ON public.browser_highlights(tab_id);
```

### TypeScript Interfaces 

```ts
interface BrowserSession {
  id: string;
  user_id: string;
  instance_id: string | null;
  persona_id: string;
  chat_id: string | null;
  status: "active" | "closed";
  last_url: string;
  last_action: string;
  created_at: string;
  closed_at: string | null;
  // Joined
  persona?: { id: string; name: string; status: string };
  tabs?: BrowserTab[];
}

interface BrowserTab {
  id: string;
  session_id: string;
  url: string;
  title: string;
  favicon_url: string;
  favicon_color: string;
  position: number;
  active: boolean;
  navigation_history: string[];
  history_position: number;
  scroll_position: number;
  opened_at: string;
  closed_at: string | null;
}

interface BrowserHistoryEntry {
  id: string;
  url: string;
  title: string;
  persona_id: string | null;
  persona_name: string;
  extract_count: number;
  visit_count: number;
  last_visited_at: string;
}

interface BrowserExtract {
  id: string;
  user_id: string;
  session_id: string | null;
  tab_id: string | null;
  instance_id: string | null;
  title: string;
  content: string;
  source_url: string;
  extract_type: "text" | "table" | "image" | "full_page";
  metadata: {
    selector?: string;
    dimensions?: { width: number; height: number };
    section_heading?: string;
    word_count?: number;
  };
  created_at: string;
  // Joined
  instance_name?: string;
}

interface BrowserHighlight {
  id: string;
  tab_id: string;
  persona_id: string | null;
  created_by: "user" | "persona";
  selector: string;
  content_preview: string;
  note: string;
  color: string;
  created_at: string;
}

interface PageExtractionData {
  title: string;
  url: string;
  headings: { level: number; text: string }[];
  tables: { headers: string[]; rows: string[][] }[];
  text: string;                     // Readable text content, max ~8000 chars
  favicon: string;
  images: { src: string; alt: string }[];
  links: { href: string; text: string }[];
}
```

---

## 11.3 Session Manager Screen (Non-Browse Mode) 

When the user navigates to Browser and is not actively browsing, they see the Session Manager. This is the landing view. The main sidebar remains visible in this mode.

### Container 

```ts
{
  padding: `${py}px ${px}px`,
  overflowY: "auto",
  height: "100%",
}

// Inner
{
  maxWidth: 900,
  margin: "0 auto",
}
```

### Header 

```ts
{
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  marginBottom: 24,
}
```

**Left side:**

- Title: "Browser" (22px, weight 300, `text`, letter-spacing \-0.03em)  
- Subtitle: "Browse the web with Personas alongside you." (13px, weight 300, `textMuted`, marginTop 4px)

**Right side:**

- "Open Browser" button:

```ts
{
  background: theme.accent,
  color: theme.accentText,
  border: "none",
  borderRadius: 8,
  padding: "8px 18px",
  fontSize: 12,
  cursor: "pointer",
  fontWeight: 450,
  display: "flex",
  alignItems: "center",
  gap: 5,
}
```

Compass icon (13px) \+ "Open Browser". Clicking enters browse mode: collapses the main sidebar, creates a new session (or resumes the most recent active session), shows the web viewport.

### Sub-Tab Navigation 

3 tabs as filter chips (gap 8px, marginBottom 20px):

| ID | Label |
| :---- | :---- |
| `sessions` | Active Sessions |
| `history` | History |
| `extracts` | Saved Extracts |

Each chip:

```ts
{
  padding: "6px 14px",
  borderRadius: 20,
  fontSize: 11,
  cursor: "pointer",
  fontWeight: 400,
  border: "none",
  background: isActive ? theme.accent : theme.surfaceAlt,
  color: isActive ? theme.accentText : theme.textMuted,
}
```

Default: "sessions".

### Active Sessions Sub-Tab 

Displays currently open browser sessions.

**Data query:**

```ts
const { data: sessions } = useQuery({
  queryKey: ["browser-sessions-active"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("browser_sessions")
      .select(`
        *,
        persona:personas(id, name, status)
      `)
      .eq("status", "active")
      .order("created_at", { ascending: false });
    return data ?? [];
  },
});
```

**Session card** (flex column, gap 10px):

```ts
{
  padding: "14px 18px",
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 12,
  display: "flex",
  alignItems: "center",
  gap: 14,
  cursor: "pointer",
}
```

- Favicon placeholder: 36×36px rounded square (borderRadius 8), `surfaceAlt` bg, Globe icon (16px, `textMuted`) centered  
- Content (flex 1):  
  - URL: 13px, weight 450, `text`. Shows `last_url` (domain \+ path, truncated to \~60 chars)  
  - Action \+ persona: `{last_action} · {persona.name}` (11px, weight 300, `textMuted`). Persona name in `textFaint`.  
- Timestamp: 10px, `textFaint`. Relative time since session creation.

Clicking a session card resumes the session: loads all tabs from `browser_tabs` where `session_id = session.id`, enters browse mode with the session's active tab URL in the viewport.

**Close session action:** On hover, a small X icon (12px, `textFaint`) appears at the right edge (absolute positioned). Clicking closes the session (sets status \= "closed", closed\_at \= now()).

### History Sub-Tab 

Chronological list of all browsing history entries.

**Data query:**

```ts
const { data: history } = useQuery({
  queryKey: ["browser-history"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("browser_history")
      .select("*")
      .order("last_visited_at", { ascending: false })
      .limit(50);
    return data ?? [];
  },
});
```

**History item** (flex column, gap 6px):

```ts
{
  padding: "12px 16px",
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 10,
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
}
```

Left side:

- URL/title: 12px, weight 400, `text`. Shows title if available, falls back to URL.  
- Metadata: `{persona_name} · {relative_time} · {extract_count} extracts` (10px, weight 300, `textFaint`, marginTop 2px)

Right side (gap 4px):

- "Revisit" button: `border: 1px solid theme.border, borderRadius: 5, padding: 3px 8px, fontSize: 10, color: textMuted`. Creates a new session with this URL.  
- Delete button: Trash2 icon (11px, `textFaint`, background none, border none, padding 3px, cursor pointer). Deletes the history entry.

### Saved Extracts Sub-Tab 

Shows content extracted from browsed pages.

**Data query:**

```ts
const { data: extracts } = useQuery({
  queryKey: ["browser-extracts"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("browser_extracts")
      .select(`
        *,
        instance:instances(id, name)
      `)
      .order("created_at", { ascending: false })
      .limit(50);
    return data ?? [];
  },
});
```

**Extract card** (flex column, gap 6px):

```ts
{
  padding: "12px 16px",
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 10,
}
```

- Top row (flex, space-between, marginBottom 4px):  
  - Title: 12px, weight 450, `text`  
  - Timestamp: 10px, `textFaint`  
- Source info: `From {source_url_domain} · Saved to {instance_name}` (10px, `textMuted`). Instance name in weight 450\.

Clicking an extract opens a preview modal showing the full extracted content, or navigates to the Instance's files tab if an instance is linked.

### Empty States 

**Active Sessions — no sessions:**

```ts
{
  padding: "40px 20px",
  textAlign: "center",
}
```

- Compass icon (28px, `textFaint`, marginBottom 12px)  
- "No active sessions" (14px, weight 400, `text`)  
- "Open the browser to start a co-browsing session with a Persona." (12px, weight 300, `textMuted`, marginTop 6px, maxWidth 360px, margin auto)

**History — no history:**

- Clock icon (28px, `textFaint`)  
- "No browsing history yet" (14px, weight 400\)  
- "Pages you visit with Personas will appear here." (12px, weight 300, `textMuted`)

**Saved Extracts — no extracts:**

- Download icon (28px, `textFaint`)  
- "No saved extracts" (14px, weight 400\)  
- "Extract content from web pages to save it to your Instances." (12px, weight 300, `textMuted`)

---

## 11.4 Co-Browse Layout (Browse Mode) 

When the user enters browse mode (via "Open Browser" button, clicking a session card, or clicking "Revisit"), the main sidebar collapses and the full content area is dedicated to the browser workspace.

### State Variables 

```ts
const [browserView, setBrowserView] = useState<"float" | "icon" | "sidebar" | "split" | "chatonly">("sidebar");
const [browserViewMenu, setBrowserViewMenu] = useState(false);       // Nav bar view menu
const [browserViewMenu2, setBrowserViewMenu2] = useState(false);     // Chat panel view menu
const [browserUrl, setBrowserUrl] = useState("https://apex-corp.com/services");
const [browserNavVisible, setBrowserNavVisible] = useState(true);    // Auto-hide after 30s
const [browserNavMinimized, setBrowserNavMinimized] = useState(false); // Collapsed to compass dot
const [browserChatInput, setBrowserChatInput] = useState("");
const [browserPersona, setBrowserPersona] = useState<string>(defaultPersonaId);
const [browserPageAware, setBrowserPageAware] = useState(false);     // "Reading this page" indicator
const [browserTabListPopup, setBrowserTabListPopup] = useState(false);
const [activeBrowserTab, setActiveBrowserTab] = useState<string>(firstTabId);
const [highlightMode, setHighlightMode] = useState(false);           // Highlight tool active
const [extractMode, setExtractMode] = useState(false);               // Extract selector active
```

### Container 

```ts
{
  display: "flex",
  height: "100%",
  position: "relative",
  overflow: "hidden",
}
```

### View Mode Layout Matrix 

The layout adapts based on the active view mode:

| View Mode | Web Viewport | Chat Panel | Tab Strip Location |
| :---- | :---- | :---- | :---- |
| `float` | Full width | Floating bubble overlaid on page | Inside viewport header |
| `icon` | Full width | 44px persona dot, bottom-right | Inside viewport header |
| `sidebar` | Remaining width (flex: 1\) | 320px fixed right panel | Inside chat panel header |
| `split` | 50% | 50% | Inside chat panel header |
| `chatonly` | Hidden | 100% | Inside chat panel header |

All modes share the same chat state and conversation history. Switching views does NOT reset the conversation. The browser session, tabs, and URL state persist across view changes.

```ts
const showWebView = browserView !== "chatonly";
const showChatPanel = browserView === "sidebar" || browserView === "split" || browserView === "chatonly";
```

---

## 11.5 Web Viewport 

The viewport area that renders the proxied web page content.

### Container 

```ts
{
  flex: browserView === "split" ? "none" : 1,
  width: browserView === "split" ? "50%" : undefined,
  display: "flex",
  flexDirection: "column",
  position: "relative",
  minWidth: 0,
  background: "#f8f8f8",          // Neutral background visible during page load
}
```

Hidden entirely when `browserView === "chatonly"`.

### Tab Strip (Float/Icon Modes) 

When the chat panel is not docked (float and icon modes), the tab strip renders above the viewport inside the viewport container:

```ts
{
  flexShrink: 0,
  borderBottom: `1px solid ${theme.borderSubtle}`,
  padding: "6px 12px",
  display: "flex",
  alignItems: "center",
  gap: 6,
  background: theme.surface,
  position: "relative",
}
```

**Tab squares:** Each open tab is a 26×26px colored square showing the site's first letter:

```ts
{
  width: 26,
  height: 26,
  borderRadius: 6,
  background: tab.favicon_color,     // Generated from domain or extracted favicon
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  cursor: "pointer",
  flexShrink: 0,
  outline: isActive
    ? `2px solid ${theme.accent}`
    : "2px solid transparent",
  outlineOffset: 1,
  opacity: isActive ? 1 : 0.55,
  transition: "opacity 0.15s ease, outline 0.15s ease",
}

// Favicon letter
{
  color: "#fff",
  fontSize: 10,
  fontWeight: 700,
}
```

Clicking a tab square switches to that tab: updates the active tab, loads its URL in the iframe, restores its scroll position.

**Spacer:** `flex: 1` between tab squares and action buttons.

**New Tab button:**

```ts
{
  background: "none",
  border: `1px dashed ${theme.border}`,
  color: theme.textFaint,
  cursor: "pointer",
  display: "flex",
  padding: 3,
  borderRadius: 6,
  width: 26,
  height: 26,
  alignItems: "center",
  justifyContent: "center",
}
```

Plus icon (11px). Creates a new tab with a blank URL and focuses the URL input in the nav bar.

**Expand button:** Maximize2 icon (12px, `textFaint`, background none, border none, padding 3px). Toggles the tab list popup.

### Tab List Popup 

A floating vertical menu showing full tab names. Triggered by the Expand button.

```ts
{
  position: "absolute",
  top: "100%",                // Below the tab strip
  right: 12,                  // Right-aligned in viewport; left-aligned in chat panel
  marginTop: 4,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  padding: 6,
  minWidth: 240,
  maxHeight: 320,
  overflowY: "auto",
  boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
  zIndex: 50,
}
```

Each tab item:

```ts
{
  padding: "7px 10px",
  borderRadius: 7,
  cursor: "pointer",
  display: "flex",
  alignItems: "center",
  gap: 10,
  background: isActive ? theme.surfaceAlt : "transparent",
}
```

- 20×20px colored favicon square (borderRadius 5, same `favicon_color`)  
- Tab title: 11px, weight 450 if active / 350 if inactive, `text` if active / `textMuted` if inactive. overflow hidden, textOverflow ellipsis, whiteSpace nowrap.  
- Active dot indicator: 5px circle, accent bg, marginLeft auto, flexShrink 0\. Only shown for the active tab.

Clicking a tab in the popup: switches to that tab and closes the popup.

**Close tab in popup (v2):** On hover, an X icon (9px, `textFaint`) appears at the right edge of each tab row. Clicking removes the tab from the session (sets `closed_at`). Cannot close the last remaining tab.

### Tab State Management 

```ts
interface ClientTabState {
  id: string;
  url: string;
  title: string;
  favicon: string;                // First letter of domain
  favicon_color: string;          // Background color derived from domain hash
  navigation_history: string[];   // URLs visited in this tab for back/forward
  history_position: number;       // Current index in navigation_history
  scroll_position: number;        // Preserved Y scroll offset
}
```

**Color generation from domain:**

```ts
function domainToColor(domain: string): string {
  const PALETTE = [
    "#e94560", "#a259ff", "#1a73e8", "#635bff",
    "#10a37f", "#ff6b35", "#2e95f3", "#f59e0b",
  ];
  let hash = 0;
  for (let i = 0; i < domain.length; i++) {
    hash = domain.charCodeAt(i) + ((hash << 5) - hash);
  }
  return PALETTE[Math.abs(hash) % PALETTE.length];
}
```

**Navigation history management:**

```ts
function navigateInTab(tabId: string, newUrl: string) {
  setTabs(prev => prev.map(tab => {
    if (tab.id !== tabId) return tab;
    // Truncate forward history when navigating to a new URL
    const truncated = tab.navigation_history.slice(0, tab.history_position + 1);
    return {
      ...tab,
      url: newUrl,
      navigation_history: [...truncated, newUrl],
      history_position: truncated.length,
      scroll_position: 0,
    };
  }));
}

function goBack(tabId: string) {
  setTabs(prev => prev.map(tab => {
    if (tab.id !== tabId || tab.history_position <= 0) return tab;
    const newPos = tab.history_position - 1;
    return {
      ...tab,
      url: tab.navigation_history[newPos],
      history_position: newPos,
    };
  }));
}

function goForward(tabId: string) {
  setTabs(prev => prev.map(tab => {
    if (tab.id !== tabId || tab.history_position >= tab.navigation_history.length - 1) return tab;
    const newPos = tab.history_position + 1;
    return {
      ...tab,
      url: tab.navigation_history[newPos],
      history_position: newPos,
    };
  }));
}
```

**Tab limits per tier:**

| Tier | Max Tabs per Session |
| :---- | :---- |
| Plus | 3 |
| Premium | 7 |
| Pro | 10 |

When at the limit, the "+" new tab button is disabled with a tooltip: "Maximum {n} tabs for your plan."

### Iframe Rendering 

```ts
<iframe
  ref={iframeRef}
  src={`${SUPABASE_URL}/functions/v1/browser-proxy?url=${encodeURIComponent(activeTab.url)}`}
  sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
  style={{
    flex: 1,
    width: "100%",
    border: "none",
  }}
  onLoad={handlePageLoad}
  onError={handlePageError}
/>
```

**handlePageLoad callback:**

```ts
function handlePageLoad() {
  // Listen for messages from the overlay script
  window.addEventListener("message", (event) => {
    if (event.data?.type === "aiconnected:page-extracted") {
      const pageData: PageExtractionData = event.data.data;

      // 1. Update tab title and favicon
      updateTabTitle(activeTab.id, pageData.title);

      // 2. Upsert browser history
      upsertBrowserHistory(pageData.url, pageData.title);

      // 3. Update session's last_url and last_action
      updateSession(sessionId, {
        last_url: pageData.url,
        last_action: `Viewing ${new URL(pageData.url).hostname}`,
      });

      // 4. Send extracted content to chat context
      setPageContext(pageData);

      // 5. Show page awareness indicator
      setBrowserPageAware(true);

      // 6. Apply any existing highlights for this tab
      loadHighlightsForTab(activeTab.id);
    }
  });
}
```

### Loading State 

While the iframe is loading, show a loading indicator overlaid on the viewport:

```ts
{
  position: "absolute",
  top: "50%",
  left: "50%",
  transform: "translate(-50%, -50%)",
  display: "flex",
  flexDirection: "column",
  alignItems: "center",
  gap: 12,
  zIndex: 5,
}
```

- Loader2 icon (20px, `textMuted`, `animation: spin 1s linear infinite`)  
- "Loading {domain}..." (11px, weight 300, `textFaint`)

---

## 11.6 Floating Navigation Bar 

The primary control surface for the browser. Floats at the bottom-center of the web viewport.

### Container 

```ts
{
  position: "absolute",
  bottom: 16,
  left: "50%",
  transform: "translateX(-50%)",
  display: "flex",
  alignItems: "center",
  gap: 6,
  background: "rgba(20,20,20,0.88)",
  backdropFilter: "blur(12px)",
  borderRadius: 14,
  padding: "7px 10px",
  zIndex: 30,
  boxShadow: "0 4px 20px rgba(0,0,0,0.25)",
}
```

Only shown when `showWebView && !browserNavMinimized && browserNavVisible`.

### Controls (Left to Right) 

All nav bar buttons share this base style unless noted:

```ts
{
  background: "none",
  border: "none",
  color: "rgba(255,255,255,0.6)",
  cursor: "pointer",
  display: "flex",
  padding: 4,
}
```

**1\. Back button:** ArrowLeft icon (14px). Calls `goBack(activeTabId)`. Disabled (opacity 0.3, cursor default) when `history_position <= 0`.

**2\. Forward button:** ArrowRight icon (14px). Calls `goForward(activeTabId)`. Disabled when `history_position >= navigation_history.length - 1`.

**3\. Refresh button:** RefreshCw icon (12px). Reloads the iframe by re-setting the src attribute with the same proxy URL.

**4\. URL input:**

```ts
{
  display: "flex",
  alignItems: "center",
  background: "rgba(255,255,255,0.1)",
  borderRadius: 8,
  padding: "4px 10px",
  flex: 1,
  minWidth: mobile ? 120 : 220,
}
```

- Globe icon (10px, rgba(255,255,255,0.4), marginRight 6px, flexShrink 0\) prefix  
- Input:

```ts
{
  border: "none",
  background: "none",
  outline: "none",
  fontSize: 11,
  color: "rgba(255,255,255,0.8)",
  fontWeight: 350,
  width: "100%",
}
```

Displays the current URL. Editable. On Enter keypress: parse the input — if it looks like a URL (contains a dot), navigate to it directly. Otherwise, prepend `https://www.google.com/search?q=` to treat it as a search query. Calls `navigateInTab(activeTabId, parsedUrl)`.

**5\. Highlight button:** ScanLine icon (13px, rgba(255,255,255,0.5)). Toggles `highlightMode`. When active, the button background changes to rgba(46,149,243,0.3) and color to rgba(255,255,255,0.9) to indicate the tool is engaged. Title tooltip: "Highlight".

**6\. Extract button:** Download icon (13px, rgba(255,255,255,0.5)). Toggles `extractMode`. Same active state visual treatment. Title tooltip: "Extract to Instance".

**7\. View Switcher:**

```ts
{
  position: "relative",
}
```

Button:

```ts
{
  background: "rgba(255,255,255,0.1)",
  border: "none",
  color: "rgba(255,255,255,0.7)",
  cursor: "pointer",
  display: "flex",
  padding: "4px 8px",
  borderRadius: 6,
  alignItems: "center",
  gap: 3,
  fontSize: 10,
}
```

Columns icon (11px) \+ "View" text (hidden on mobile) \+ ChevronDown icon (9px). Toggles `browserViewMenu`. See Section 11.7 for the dropdown menu.

**8\. Minimize button:** Minimize2 icon (11px, rgba(255,255,255,0.4)). Sets `browserNavMinimized = true`. Title tooltip: "Minimize navigation".

### Auto-Hide Behavior 

The nav bar auto-hides after 30 seconds of mouse inactivity within the viewport:

```ts
useEffect(() => {
  let timeout: ReturnType<typeof setTimeout>;

  const handleMouseMove = () => {
    setBrowserNavVisible(true);
    clearTimeout(timeout);
    timeout = setTimeout(() => setBrowserNavVisible(false), 30000);
  };

  // Only attach to viewport area
  const viewport = viewportRef.current;
  if (viewport) {
    viewport.addEventListener("mousemove", handleMouseMove);
  }

  return () => {
    if (viewport) viewport.removeEventListener("mousemove", handleMouseMove);
    clearTimeout(timeout);
  };
}, []);
```

Moving the mouse restores the nav bar and resets the 30-second timer. The transition uses opacity: `transition: opacity 0.3s ease`.

### Minimized Nav Dot 

When the nav bar is minimized, it collapses to a compass dot:

```ts
{
  position: "absolute",
  bottom: 16,
  left: "50%",
  transform: "translateX(-50%)",
  width: 36,
  height: 36,
  borderRadius: "50%",
  background: "rgba(20,20,20,0.85)",
  backdropFilter: "blur(8px)",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  cursor: "pointer",
  zIndex: 30,
  boxShadow: "0 4px 12px rgba(0,0,0,0.2)",
}
```

Compass icon (16px, rgba(255,255,255,0.7)). Clicking restores the full nav bar: `setBrowserNavMinimized(false)`.

---

## 11.7 View Switcher Menu 

Shared dropdown used from both the floating nav bar and the chat panel header. The 5 view mode options plus 2 additional actions.

### From Floating Nav Bar 

Opens upward (bottom: 100%, marginBottom 6px), right-aligned:

```ts
{
  position: "absolute",
  bottom: "100%",
  right: 0,
  marginBottom: 6,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 12,
  padding: 6,
  minWidth: 200,
  boxShadow: "0 8px 24px rgba(0,0,0,0.2)",
  zIndex: 40,
}
```

### From Chat Panel Header 

Opens downward (top: 100%, marginTop 4px), right-aligned. Same styling.

### Section Header 

```ts
{
  padding: "4px 10px 6px",
  fontSize: 10,
  color: theme.textFaint,
  fontWeight: 500,
  textTransform: "uppercase",
  letterSpacing: "0.05em",
}
// Text: "CHAT LAYOUT"
```

### 5 View Options 

| ID | Label | Description | Icon |
| :---- | :---- | :---- | :---- |
| `float` | Float Bar | Floating chat input over the page | CircleDot (13px) |
| `icon` | Icon Only | Persona dot — click to expand | Minimize2 (13px) |
| `sidebar` | Sidebar | Chat panel docked to the right | PanelRightOpen (13px) |
| `split` | 50/50 | Equal split between browser and chat | Columns (13px) |
| `chatonly` | Chat Only | Full chat with browser minimized | MessageSquare (13px) |

Each option row:

```ts
{
  padding: "7px 10px",
  borderRadius: 7,
  cursor: "pointer",
  display: "flex",
  alignItems: "center",
  gap: 10,
  background: isActive ? theme.surfaceAlt : "transparent",
}
```

- Icon: `accent` color if active, `textFaint` if inactive  
- Label: 12px, weight 500 if active / 400 if inactive, `text`  
- Description: 10px, weight 300, `textFaint`

Clicking an option: sets `browserView`, closes the menu.

### Divider 

```ts
{
  borderTop: `1px solid ${theme.borderSubtle}`,
  margin: "6px 0",
}
```

### Additional Actions (Below Divider) 

Each action row: same padding/borderRadius as options, but 12px label text, `textMuted`, weight 400\.

| Icon | Label | Action |
| :---- | :---- | :---- |
| Minimize2 (13px, `textFaint`) | Minimize nav to dot | `setBrowserNavMinimized(true)`. Closes menu. |
| History (13px, `textFaint`) | Sessions & History | Exits browse mode and returns to session manager. Closes menu. |

---

## 11.8 Chat View Modes — Complete Specifications 

### 11.8.1 Float Bar (Default for first-time users) 

A floating chat bubble overlaid on the web viewport. This is the least intrusive chat mode.

```ts
{
  position: "absolute",
  bottom: 80,                      // Positioned above the floating nav bar
  left: "50%",
  transform: "translateX(-50%)",
  width: mobile ? "90%" : 440,
  background: theme.surface,
  borderRadius: 16,
  boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
  border: `1px solid ${theme.borderSubtle}`,
  padding: "10px 14px",
  display: "flex",
  flexDirection: "column",
  gap: 8,
  zIndex: 20,
}
```

**Last message preview** (top section):

```ts
{
  display: "flex",
  gap: 8,
  alignItems: "flex-start",
}
```

- 22px persona avatar  
- Content (flex 1):  
  - Persona name: 10px, weight 500, `text`, marginBottom 2px  
  - Message text: 11px, weight 350, `textSec`, lineHeight 1.4. Truncated to 3 lines with overflow ellipsis.

If no messages exist yet, show: "Start a conversation about this page" (11px, weight 300, `textFaint`, fontStyle italic).

**Input row:**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  background: theme.surfaceAlt,
  borderRadius: 10,
  padding: "7px 10px",
}
```

- Input: `Ask {persona_name}...` placeholder, 12px, weight 350, `text`, flex 1  
- Mic icon (13px, `textFaint`, cursor pointer)  
- ArrowUp send icon (13px, `textMuted`, cursor pointer). On click or Enter: sends the message to the co-browse chat.

### 11.8.2 Icon Only 

Minimized to a single persona avatar dot. Maximum page real estate.

```ts
{
  position: "absolute",
  bottom: 80,
  right: 24,
  width: 44,
  height: 44,
  borderRadius: "50%",
  background: theme.accent,
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  cursor: "pointer",
  boxShadow: "0 4px 16px rgba(0,0,0,0.2)",
  zIndex: 20,
}
```

- 34px persona avatar inside (rendered with inverted theme colors: `surfaceAlt: "rgba(255,255,255,0.15)"`, `textMuted: "#fff"` so the avatar letter is visible against the accent background)  
- Green status dot: 10px circle, `dot.active` bg, positioned absolute bottom: 0, right: 0, `border: 2px solid theme.surface`

Clicking expands to Float Bar mode: `setBrowserView("float")`.

### 11.8.3 Sidebar (320px) 

A fixed-width chat panel docked to the right of the viewport. This is the default view mode.

```ts
{
  width: 320,
  borderLeft: `1px solid ${theme.borderSubtle}`,
  background: theme.surface,
  display: "flex",
  flexDirection: "column",
  height: "100%",
  flexShrink: 0,
}
```

Internal layout: Tab Strip → Persona Header → Chat Messages → Composer \+ Quick Actions. See Section 11.9 for full chat panel spec.

### 11.8.4 50/50 Split 

Equal horizontal split between the viewport and the chat panel.

```ts
// Viewport
{
  width: "50%",
  // ... same flex/position styles
}

// Chat panel
{
  width: "50%",
  borderLeft: `1px solid ${theme.borderSubtle}`,
  // ... same flex/column styles
}
```

Same internal layout as Sidebar mode, but with more horizontal space for comfortable chat. Message text can be wider, avatars larger.

### 11.8.5 Chat Only 

Full-width chat panel, no viewport visible.

```ts
{
  width: "100%",
  background: theme.surface,
  display: "flex",
  flexDirection: "column",
  height: "100%",
}
```

The web viewport is hidden (`showWebView = false`). The tab strip remains visible at the top of the chat panel so the user can reference which page they were viewing. The user can switch back to any other view mode to reveal the viewport again.

---

## 11.9 Chat Panel (Sidebar / Split / ChatOnly) 

The persistent chat panel used in sidebar, split, and chatonly view modes. Contains the tab strip, persona header, chat messages, composer, and quick action chips.

### Tab Strip (Inside Chat Panel) 

When the chat panel is docked (sidebar, split, chatonly modes), the tab strip renders at the top of the chat panel, not inside the viewport:

```ts
{
  flexShrink: 0,
  borderBottom: `1px solid ${theme.borderSubtle}`,
  padding: "6px 12px",
  display: "flex",
  alignItems: "center",
  gap: 6,
  position: "relative",
}
```

Same tab squares (26×26px), new tab button, expand button, and tab list popup as Section 11.5. The popup alignment is `left: 12` instead of `right: 12` since the panel is on the right side.

### Persona Header 

```ts
{
  padding: "10px 14px",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  flexShrink: 0,
}
```

**Left side** (gap 8px):

- 22px persona avatar  
- Content:  
  - Name: 12px, weight 500, `text`  
  - Status: "Co-browsing · Page aware" (9px, weight 400, `dot.active` green color). Shows "Co-browsing" when pageAware is false.

**Right side** (gap 3px, alignItems center):

- View switcher button:

```ts
{
  background: browserView === "chatonly" ? theme.surfaceAlt : "none",
  border: "none",
  color: theme.textFaint,
  cursor: "pointer",
  display: "flex",
  padding: "3px 5px",
  borderRadius: 5,
  alignItems: "center",
  gap: 2,
}
```

Columns icon (12px). In chatonly mode, additionally shows "View" text (10px, `textMuted`) since there's no nav bar view switcher visible. ChevronDown icon (9px). Toggles `browserViewMenu2`.

- Switch Persona button: Users icon (13px, `textFaint`, background none, border none, padding 3px). Title tooltip: "Switch Persona". Clicking opens a dropdown of available personas to swap the co-browsing companion. Each option: 20px avatar \+ name (11px) \+ role (10px, `textFaint`).  
    
- Sessions button: History icon (13px, `textFaint`, background none, border none, padding 3px). Title tooltip: "Sessions". Returns to session manager.

### Chat Messages 

```ts
{
  flex: 1,
  overflowY: "auto",
  padding: "12px 14px",
}
```

Messages are stored in a standard chat linked to the browser session via `browser_sessions.chat_id`. The chat is created automatically when the session starts:

```ts
async function createBrowserChat(sessionId: string, personaId: string) {
  const supabase = createClient();

  const { data: chat } = await supabase
    .from("chats")
    .insert({
      user_id: userId,
      title: "Co-browsing session",
      chat_type: "browser",
      metadata: { browser_session_id: sessionId },
    })
    .select()
    .single();

  // Add persona as participant
  await supabase.from("chat_participants").insert({
    chat_id: chat.id,
    persona_id: personaId,
  });

  // Link to session
  await supabase
    .from("browser_sessions")
    .update({ chat_id: chat.id })
    .eq("id", sessionId);

  return chat;
}
```

Each message bubble:

```ts
{
  marginBottom: 14,
  display: "flex",
  gap: 8,
}
```

- 20px avatar (persona avatar for AI messages, user avatar for user messages)  
- Content (flex 1):  
  - Sender name: 11px, weight 500, `text`, marginBottom 2px. "You" for user messages, persona name for AI messages.  
  - Message text: 12px, weight 350, `textSec`, lineHeight 1.5

Messages follow the standard chat message model (Part 5). The persona's response is assembled with the page context injected as a system message (see Section 11.11).

### Composer 

```ts
{
  padding: "10px 14px",
  borderTop: `1px solid ${theme.borderSubtle}`,
  flexShrink: 0,
}
```

**Input row:**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 6,
  background: theme.surfaceAlt,
  borderRadius: 10,
  padding: "8px 10px",
}
```

- Input: `Ask {persona_name} about this page...` placeholder, 12px, weight 350, flex 1  
- Mic icon (13px, `textFaint`, cursor pointer)  
- Paperclip icon (13px, `textFaint`, cursor pointer). Opens file attachment (same as Part 5 composer).  
- ArrowUp send icon (13px, `textMuted`, cursor pointer)

On send: creates a new message in the linked chat, triggers the AI response pipeline (Part 5\) with the page context injected.

### Quick Action Chips 

Below the composer (marginTop 8px, display flex, gap 6px, flexWrap wrap):

```ts
{
  padding: "3px 8px",
  borderRadius: 6,
  fontSize: 10,
  border: `1px solid ${theme.border}`,
  background: "none",
  color: theme.textMuted,
  cursor: "pointer",
  fontWeight: 350,
}
```

4 contextual action chips:

| Chip Label | On Click Action |
| :---- | :---- |
| "Summarize page" | Inserts the prompt "Summarize the key points of this page" into the composer and auto-sends it. The persona responds using the page context. |
| "Extract pricing" | Sends `extractMode` postMessage to the iframe, targeting elements containing price patterns ($, €, pricing, plan). Extracted content is presented in the Extract confirmation popover (Section 11.10). |
| "Find contact info" | Inserts the prompt "Find all contact information on this page (emails, phone numbers, addresses)" and auto-sends. The persona parses the page text and lists found contacts. |
| "Compare to Instance" | Opens an Instance picker dropdown. Once selected, inserts the prompt "Compare this page's content with what we have in the {instance\_name} workspace" and auto-sends. Cipher retrieves the Instance's workspace context and includes it alongside the page context. |

These are the default chips. In v2, Cipher will analyze the page type (e-commerce, documentation, article, SaaS landing page, etc.) and surface context-appropriate actions. For example, a recipe page might show "Extract ingredients" and "Convert to metric".

---

## 11.10 Page Interaction Layer 

### Overlay Script (browser-overlay.js) 

The overlay script is injected into every proxied page. It handles content extraction, highlight rendering, and section selection. All communication with the parent frame happens via `postMessage`.

```javascript
// public/browser-overlay.js
(function() {
  "use strict";

  // ─── CONTENT EXTRACTION ───
  const extractContent = () => {
    // Prefer semantic containers
    const article = document.querySelector("article, main, [role='main']")
      || document.body;

    const headings = [...document.querySelectorAll("h1, h2, h3, h4")]
      .map(h => ({
        level: parseInt(h.tagName[1]),
        text: h.textContent.trim(),
      }))
      .filter(h => h.text.length > 0);

    const tables = [...document.querySelectorAll("table")].map(t => ({
      headers: [...t.querySelectorAll("th")].map(th => th.textContent.trim()),
      rows: [...t.querySelectorAll("tbody tr, tr")]
        .slice(0, 50) // Limit rows
        .map(tr => [...tr.querySelectorAll("td")].map(td => td.textContent.trim())),
    }));

    const images = [...document.querySelectorAll("img")]
      .filter(img => img.naturalWidth > 100 && img.naturalHeight > 100) // Skip tiny images
      .slice(0, 20)
      .map(img => ({ src: img.src, alt: img.alt || "" }));

    const links = [...document.querySelectorAll("a[href]")]
      .filter(a => a.href && !a.href.startsWith("javascript:"))
      .slice(0, 50)
      .map(a => ({ href: a.href, text: a.textContent.trim() }));

    // Extract readable text (limited to ~8000 chars)
    const text = article.innerText.substring(0, 8000);

    return {
      title: document.title,
      url: window.location.href,
      headings,
      tables,
      text,
      images,
      links,
      favicon: document.querySelector("link[rel*='icon']")?.href || "",
    };
  };

  // Send extracted content to parent frame
  window.parent.postMessage({
    type: "aiconnected:page-extracted",
    data: extractContent(),
  }, "*");

  // ─── HIGHLIGHT MODE ───
  let highlightActive = false;
  let hoverOutline = null;

  function enableHighlightMode() {
    highlightActive = true;
    document.body.style.cursor = "crosshair";

    document.addEventListener("mouseover", highlightHover);
    document.addEventListener("mouseout", highlightUnhover);
    document.addEventListener("click", highlightClick, true); // Capture phase
  }

  function disableHighlightMode() {
    highlightActive = false;
    document.body.style.cursor = "";
    if (hoverOutline) {
      hoverOutline.style.outline = "";
      hoverOutline = null;
    }
    document.removeEventListener("mouseover", highlightHover);
    document.removeEventListener("mouseout", highlightUnhover);
    document.removeEventListener("click", highlightClick, true);
  }

  function highlightHover(e) {
    if (!highlightActive) return;
    if (hoverOutline) hoverOutline.style.outline = "";
    hoverOutline = e.target;
    hoverOutline.style.outline = "2px dashed rgba(46,149,243,0.5)";
  }

  function highlightUnhover(e) {
    if (e.target === hoverOutline && hoverOutline) {
      hoverOutline.style.outline = "";
    }
  }

  function highlightClick(e) {
    if (!highlightActive) return;
    e.preventDefault();
    e.stopPropagation();

    const el = e.target;
    const selector = generateSelector(el);
    const preview = el.textContent.substring(0, 200);

    // Notify parent
    window.parent.postMessage({
      type: "aiconnected:highlight-created",
      data: { selector, content_preview: preview },
    }, "*");

    disableHighlightMode();
  }

  // ─── EXTRACT MODE ───
  let extractActive = false;

  function enableExtractMode() {
    extractActive = true;
    document.body.style.cursor = "copy";

    document.addEventListener("mouseover", highlightHover);
    document.addEventListener("mouseout", highlightUnhover);
    document.addEventListener("click", extractClick, true);
  }

  function disableExtractMode() {
    extractActive = false;
    document.body.style.cursor = "";
    if (hoverOutline) {
      hoverOutline.style.outline = "";
      hoverOutline = null;
    }
    document.removeEventListener("mouseover", highlightHover);
    document.removeEventListener("mouseout", highlightUnhover);
    document.removeEventListener("click", extractClick, true);
  }

  function extractClick(e) {
    if (!extractActive) return;
    e.preventDefault();
    e.stopPropagation();

    const el = e.target.closest("section, article, div, table, ul, ol") || e.target;
    const selector = generateSelector(el);

    // Determine extract type
    let extractType = "text";
    if (el.tagName === "TABLE") extractType = "table";
    else if (el.tagName === "IMG") extractType = "image";

    const content = extractType === "table"
      ? el.outerHTML
      : el.innerText.substring(0, 10000);

    const heading = el.querySelector("h1, h2, h3, h4")?.textContent || "";

    window.parent.postMessage({
      type: "aiconnected:extract-selected",
      data: {
        selector,
        content,
        extract_type: extractType,
        heading: heading.trim(),
        word_count: content.split(/\s+/).length,
      },
    }, "*");

    disableExtractMode();
  }

  // ─── RENDER HIGHLIGHTS ───
  function renderHighlight(data) {
    const { selector, personaName, note, color } = data;
    const el = document.querySelector(selector);
    if (!el) return;

    el.style.outline = `2px solid ${color || "#2e95f3"}`;
    el.style.outlineOffset = "2px";
    el.style.borderRadius = "8px";
    el.style.position = "relative";

    // Attribution badge
    const badge = document.createElement("div");
    badge.style.cssText = `
      position: absolute; top: -10px; left: 16px;
      background: ${color || "#2e95f3"}; color: #fff;
      font-size: 9px; font-weight: 600;
      padding: 2px 8px; border-radius: 4px;
      display: flex; align-items: center; gap: 4px;
      z-index: 9999; pointer-events: none;
      font-family: -apple-system, BlinkMacSystemFont, sans-serif;
    `;
    badge.innerHTML = `<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> ${personaName} highlighted this section`;
    el.style.position = "relative";
    el.prepend(badge);
  }

  // ─── LISTEN FOR COMMANDS FROM PARENT ───
  window.addEventListener("message", (event) => {
    switch (event.data?.type) {
      case "aiconnected:enable-highlight":
        enableHighlightMode();
        break;
      case "aiconnected:disable-highlight":
        disableHighlightMode();
        break;
      case "aiconnected:enable-extract":
        enableExtractMode();
        break;
      case "aiconnected:disable-extract":
        disableExtractMode();
        break;
      case "aiconnected:render-highlight":
        renderHighlight(event.data.data);
        break;
    }
  });

  // ─── UTILITY: Generate CSS selector for element ───
  function generateSelector(el) {
    if (el.id) return `#${el.id}`;
    const path = [];
    while (el && el !== document.body) {
      let selector = el.tagName.toLowerCase();
      if (el.className && typeof el.className === "string") {
        const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
        if (classes) selector += `.${classes}`;
      }
      const siblings = el.parentElement
        ? [...el.parentElement.children].filter(c => c.tagName === el.tagName)
        : [];
      if (siblings.length > 1) {
        selector += `:nth-of-type(${siblings.indexOf(el) + 1})`;
      }
      path.unshift(selector);
      el = el.parentElement;
    }
    return path.join(" > ");
  }
})();
```

### Highlight Tool Flow 

When the user clicks the Highlight button (ScanLine) in the nav bar:

1. Parent sends `aiconnected:enable-highlight` to the iframe  
2. Cursor changes to crosshair in the iframe  
3. Hovering over elements shows a dashed blue outline preview (2px dashed rgba(46,149,243,0.5))  
4. Clicking an element: a. Generates a CSS selector for the element b. Extracts the first 200 characters of text content c. Sends `aiconnected:highlight-created` to the parent frame with selector \+ preview  
5. Parent frame receives the message: a. Inserts into `browser_highlights` table with the active persona as creator b. Sends `aiconnected:render-highlight` back to iframe to render the permanent highlight (solid blue border \+ attribution badge) c. Sends the highlighted content as a system message to the chat context: "The user highlighted a section: '{preview}'. You can reference this in your response." d. Deactivates highlight mode

**Persona-initiated highlights:** When a persona identifies something noteworthy on the page (e.g., the pricing section), it can include a `[highlight:CSS_SELECTOR]` tag in its response. Cipher parses this tag and sends the highlight render command to the iframe. The attribution badge shows the persona's name.

### Extract to Instance Flow 

When the user clicks the Extract button (Download) in the nav bar:

1. Parent sends `aiconnected:enable-extract` to the iframe  
     
2. Cursor changes to "copy" in the iframe  
     
3. Hovering shows the same blue outline preview  
     
4. Clicking an element (or its nearest block-level ancestor): a. Determines extract type (text, table, image) b. Extracts content (innerText for text, outerHTML for tables, src for images) c. Sends `aiconnected:extract-selected` to the parent  
     
5. Parent receives the message and shows the **Extract Confirmation Popover**:

```ts
// Positioned near the Extract button in the nav bar, or centered on mobile
{
  position: "absolute",
  bottom: 60,
  left: "50%",
  transform: "translateX(-50%)",
  width: mobile ? "90%" : 380,
  background: theme.surface,
  borderRadius: 12,
  boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
  border: `1px solid ${theme.border}`,
  padding: 16,
  zIndex: 40,
}
```

**Popover contents:**

- Header: "Save Extract" (14px, weight 450, marginBottom 12px)  
- Content preview: extracted text truncated to 3 lines (12px, weight 300, `textSec`, lineHeight 1.5, `surfaceAlt` bg, padding 8px 10px, borderRadius 8, maxHeight 72px, overflowY hidden, marginBottom 12px)  
- Title input: Label "Title" (11px, weight 400, `textMuted`). Input pre-filled with the section heading or "Extract from {domain}" (13px, `surfaceAlt` bg, border, borderRadius 8, padding 8px 12px, marginBottom 10px)  
- Instance picker: Label "Save to Instance" (11px, weight 400, `textMuted`). Dropdown listing the user's instances (12px, `surfaceAlt` bg, border, borderRadius 8, padding 8px 12px, marginBottom 10px). Option "(No instance)" for standalone saves.  
- Extract type display: "Type: {Text|Table|Image|Full Page}" (10px, `textFaint`, marginBottom 12px)  
- Buttons (flex, justifyContent flex-end, gap 6px):  
  - "Cancel" button: bordered, 11px, `textMuted`  
  - "Save" button: accent bg, accentText, 11px, weight 450  
6. On "Save":

```ts
async function saveExtract(data: ExtractData) {
  const supabase = createClient();

  // 1. Insert extract
  const { data: extract } = await supabase
    .from("browser_extracts")
    .insert({
      user_id: userId,
      session_id: currentSessionId,
      tab_id: activeTabId,
      instance_id: data.instanceId || null,
      title: data.title,
      content: data.content,
      source_url: activeTab.url,
      extract_type: data.extractType,
      metadata: {
        selector: data.selector,
        section_heading: data.heading,
        word_count: data.wordCount,
      },
    })
    .select()
    .single();

  // 2. If instance selected, also create a workspace_items reference
  if (data.instanceId) {
    await supabase.from("workspace_items").insert({
      instance_id: data.instanceId,
      type: "extract",
      title: data.title,
      content: data.content,
      source_url: activeTab.url,
      metadata: { browser_extract_id: extract.id },
    });
  }

  // 3. Increment extract_count in browser_history
  await supabase.rpc("increment_extract_count", {
    p_user_id: userId,
    p_url: activeTab.url,
  });

  // 4. Notify in chat
  sendSystemMessage(chatId,
    `Saved extract "${data.title}" from ${new URL(activeTab.url).hostname}` +
    (data.instanceId ? ` to ${data.instanceName}` : "")
  );

  // 5. Activity log
  await supabase.from("activity_log").insert({
    user_id: userId,
    actor_type: "user",
    action: "extracted content",
    entity_type: "browser_extract",
    entity_id: extract.id,
    entity_name: data.title,
  });
}
```

---

## 11.11 Page Awareness System 

When the overlay script extracts page content and sends it to the parent frame, a badge appears in the viewport indicating the persona is "reading" the page.

### Badge 

```ts
{
  position: "absolute",
  top: 8,
  right: 8,
  display: "flex",
  alignItems: "center",
  gap: 6,
  background: "rgba(46,149,243,0.9)",
  borderRadius: 8,
  padding: "4px 10px",
  zIndex: 20,
  boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
}
```

- Eye icon (10px, white)  
- Text: `{persona_name} is reading this page` (9px, white, weight 500\)  
- Dismiss button: X icon (9px, rgba(255,255,255,0.6), background none, border none, cursor pointer, padding 1px). Clicking sets `browserPageAware = false`.

The badge is dismissible. It does not reappear until the user navigates to a different URL (tab change or new navigation).

### Context Injection 

When the page awareness indicator appears, the extracted content has been processed and injected into the chat's context assembly (Part 5, Section 5.8). The page context is added as a system message before the persona's next response:

```ts
function buildPageContextMessage(pageData: PageExtractionData): Message {
  const headingOutline = pageData.headings
    .map(h => `${"#".repeat(h.level)} ${h.text}`)
    .join("\n");

  const tablesSummary = pageData.tables.length > 0
    ? `\n\nTables found on page (${pageData.tables.length}):\n` +
      pageData.tables.map((t, i) =>
        `Table ${i + 1}: ${t.headers.join(" | ")}\n` +
        t.rows.slice(0, 5).map(r => r.join(" | ")).join("\n") +
        (t.rows.length > 5 ? `\n... and ${t.rows.length - 5} more rows` : "")
      ).join("\n\n")
    : "";

  return {
    role: "system",
    content: `[PAGE CONTEXT]
The user is currently viewing: ${pageData.url}
Page title: ${pageData.title}

Page outline:
${headingOutline}

Page content (extracted):
${pageData.text}
${tablesSummary}

You can reference specific sections of this page in your response.
If the user asks about the page, use this extracted content to answer.
When referencing page sections, be specific about which part you're discussing.
If you identify something particularly noteworthy, you can suggest highlighting it.`,
  };
}
```

This message is included in the context window for every subsequent message in the co-browse chat, until the user navigates to a different URL (at which point the page context is replaced with the new page's content).

---

## 11.12 Session Lifecycle 

### Creating a Session 

Sessions are created in two scenarios:

**1\. "Open Browser" button (new session):**

```ts
async function createBrowserSession(personaId: string, initialUrl?: string) {
  const supabase = createClient();

  // 1. Create session
  const { data: session } = await supabase
    .from("browser_sessions")
    .insert({
      user_id: userId,
      persona_id: personaId,
      status: "active",
      last_url: initialUrl || "",
    })
    .select()
    .single();

  // 2. Create initial tab
  const url = initialUrl || "about:blank";
  await supabase.from("browser_tabs").insert({
    session_id: session.id,
    url,
    title: initialUrl ? "" : "New Tab",
    position: 0,
    active: true,
    navigation_history: [url],
    history_position: 0,
  });

  // 3. Create linked chat
  const chat = await createBrowserChat(session.id, personaId);

  // 4. Enter browse mode
  setBrowserMode("browse");
  setCurrentSessionId(session.id);

  return session;
}
```

**2\. "Revisit" from history (new session with pre-filled URL):**

Calls `createBrowserSession(defaultPersonaId, historyEntry.url)`.

### Resuming a Session 

Clicking an active session card in the session manager:

```ts
async function resumeSession(sessionId: string) {
  const supabase = createClient();

  // 1. Load all open tabs
  const { data: tabs } = await supabase
    .from("browser_tabs")
    .select("*")
    .eq("session_id", sessionId)
    .is("closed_at", null)
    .order("position");

  // 2. Find the active tab
  const activeTab = tabs?.find(t => t.active) || tabs?.[0];

  // 3. Load highlights for the active tab
  const { data: highlights } = await supabase
    .from("browser_highlights")
    .select("*")
    .eq("tab_id", activeTab.id);

  // 4. Enter browse mode with loaded state
  setBrowserMode("browse");
  setCurrentSessionId(sessionId);
  setTabs(tabs);
  setActiveBrowserTab(activeTab.id);
  setBrowserUrl(activeTab.url);

  // 5. Restore highlights after page loads
  // (highlights are re-rendered when the page-extracted event fires)
}
```

### Closing a Session 

Via the X button on a session card, or navigating away:

```ts
async function closeSession(sessionId: string) {
  const supabase = createClient();

  await supabase
    .from("browser_sessions")
    .update({ status: "closed", closed_at: new Date().toISOString() })
    .eq("id", sessionId);

  // Close all tabs
  await supabase
    .from("browser_tabs")
    .update({ closed_at: new Date().toISOString() })
    .eq("session_id", sessionId)
    .is("closed_at", null);
}
```

---

## 11.13 Security & Limitations 

### Proxy Security 

| Concern | Mitigation |
| :---- | :---- |
| XSS via proxied content | Sandboxed iframe with `sandbox="allow-scripts allow-same-origin allow-forms allow-popups"`. Overlay script communicates only via postMessage with origin checking. |
| Cookie leakage | Proxy does not forward the user's browser cookies. All sessions are fully isolated — the proxy has no cookie jar. |
| Malicious redirects | Proxy follows redirects server-side with a maximum of 5 hops. Final URL is validated before returning content. |
| Internal network access (SSRF) | Block requests to localhost, 127.0.0.1, 10.x.x.x, 172.16–31.x.x, 192.168.x.x, and other private ranges. |
| Rate abuse | 60 proxy requests per minute per user. Exceeding returns HTTP 429 with message: "You're browsing too fast. Please wait a moment." |
| Harmful content | URL blocklist for known harmful domains. Content-type validation (only serve HTML). |
| Data exfiltration from overlay | Overlay script only sends structured data (PageExtractionData, highlight selectors, extract content) via postMessage — never raw DOM or cookies. |

### Known Limitations (v1 — Proxy Mode) 

| Limitation | User-Facing Fallback |
| :---- | :---- |
| Login-required pages | Error view: "This page requires authentication. You can copy the URL to browse it directly." \+ "Open in new tab" button. |
| Heavy SPA frameworks (React, Angular, Vue without SSR) | Proxy serves initial HTML; client-rendered content will be missing. Error view: "This page uses dynamic loading that our browser can't fully render. Try a different page or open this one in a new tab." |
| Sites with X-Frame-Options: DENY | Error view with "Open in new tab" option. |
| Video/audio content | Playback may not work through the proxy. Show placeholder: "Media playback is not supported in the co-browser." |
| File downloads | Intercepted by the proxy. Show download link that opens in a new tab. |
| WebSocket / real-time sites | WebSocket connections won't route through the proxy. Pages relying on WebSockets will appear static. |
| Very large pages (\>10MB) | Content truncated at 10MB. Pages may appear incomplete. |

### Desktop Version Advantages (v2) 

The Electron/Tauri desktop app removes all proxy limitations:

- Full cookie/session support (user can log into sites normally)  
- No same-origin restrictions — every site works  
- Native DOM access for precise highlighting without overlay injection  
- Video/audio playback  
- File download handling (routed to the Files system)  
- Full SPA rendering  
- WebSocket support  
- No SSRF risk (browsing happens client-side)

---

## 11.14 Tier Gating 

| Feature | Free | Plus | Premium | Pro |
| :---- | :---- | :---- | :---- | :---- |
| Browse with personas | — | ✓ | ✓ | ✓ |
| Max tabs per session | — | 3 | 7 | 10 |
| Saved extracts total | — | 50 | 200 | Unlimited |
| Browser history retention | — | 7 days | 30 days | Unlimited |
| Highlights per page | — | 3 | 10 | Unlimited |
| Desktop browser (future) | — | — | ✓ | ✓ |

### Free-Tier Gated Screen 

When a Free-tier user navigates to the Browser screen:

```ts
{
  padding: "80px 20px",
  textAlign: "center",
  maxWidth: 420,
  margin: "0 auto",
}
```

- Compass icon (36px, `textFaint`, marginBottom 16px)  
- "Co-Browsing" (20px, weight 400, `text`)  
- "Browse the web with AI Personas alongside you. They can read pages, highlight sections, and extract content. Available on Plus and above." (13px, weight 300, `textMuted`, lineHeight 1.6, marginTop 12px)  
- "Upgrade to Plus" button (accent bg, accentText, borderRadius 8, padding 10px 28px, fontSize 13, fontWeight 450, marginTop 24px). Opens pricing modal.

---

*End of Part 11\. Proceed to Part 12: Insights & Analytics Screen.*

# PART 12: INSIGHTS & ANALYTICS SCREEN 

---

Insights & Analytics gives users visibility into how they and their personas work together. It surfaces usage patterns, persona effectiveness, model consumption, memory health, storage usage, and activity trends across the platform. This screen is read-only — it presents aggregated data, not configuration. Every metric shown here is derived from existing tables documented in Parts 1–11.

The screen is available to all tiers. Free and Plus tiers see basic metrics. Premium and Pro tiers unlock time-range filtering, export capabilities, deeper breakdowns, and activity trend charts. The analytics architecture prioritizes query-time aggregation in v1 with a clear path to materialized views in v2 for performance.

---

## 12.1 Data Architecture 

### No Dedicated Analytics Tables (v1) 

All insights are computed at query time from existing tables. No dedicated analytics or metrics tables are required for v1. This keeps the data model simple and avoids sync issues between operational and analytical data.

The following source tables feed the analytics system:

| Source Table | Metrics Derived |
| :---- | :---- |
| `chats` | Total conversations, conversation trends, daily activity chart, activity patterns |
| `messages` | Average conversation length, token consumption, model usage distribution, response counts |
| `chat_participants` | Persona utilization, most active persona, per-persona conversation counts |
| `personas` | Persona count, status breakdown, skill ceiling utilization |
| `persona_memories` | Memory counts by type, growth rates, flagged memories, memory health |
| `persona_skills` | Skill counts per persona, learning velocity |
| `files` | File counts, storage usage, AI-generated vs uploaded ratio |
| `search_history` | Search volume, search frequency |
| `activity_log` | Activity heatmap, peak usage hours, general engagement |
| `user_api_keys` | BYOK detection for token attribution |

### TypeScript Response Interfaces 

```ts
// Overview tab response
interface OverviewMetrics {
  total_conversations: MetricCard;
  persona_utilization: MetricCard;
  memories_created: MetricCard;
  top_model: MetricCard;
  most_active_persona: MetricCard;
  avg_conversation_length: MetricCard;
  tokens_this_month: MetricCard;
  searches_performed: MetricCard;
  daily_activity: DailyActivityPoint[];
  storage_usage: StorageUsage;
}

interface MetricCard {
  value: string | number;
  trend: string;
  trend_direction: "positive" | "negative" | "neutral";
}

interface DailyActivityPoint {
  date: string;       // ISO date string, e.g. "2026-02-13"
  conversations: number;
  messages: number;
}

interface StorageUsage {
  used_bytes: number;
  total_bytes: number;
  file_count: number;
  ai_generated_count: number;
  uploaded_count: number;
}

// Persona Performance tab response
interface PersonaPerformanceData {
  personas: PersonaPerformanceEntry[];
}

interface PersonaPerformanceEntry {
  id: string;
  name: string;
  role: string;
  status: string;
  memory_count: number;
  decision_count: number;
  skill_count: number;
  skill_ceiling: number;
  conversation_count: number;
  message_count: number;
  last_active_at: string | null;
}

// Model Usage tab response
interface ModelUsageData {
  models: ModelUsageEntry[];
  total_tokens: number;
  total_estimated_cost: number;
  byok_active: boolean;
}

interface ModelUsageEntry {
  model: string;
  display_name: string;
  provider: string;
  call_count: number;
  usage_pct: number;
  prompt_tokens: number;
  completion_tokens: number;
  estimated_cost: number;
}

// Memory Health tab response
interface MemoryHealthData {
  total: number;
  decisions: number;
  decisions_this_week: number;
  facts: number;
  preferences: number;
  preferences_updated_recently: number;
  skills: number;
  active_persona_count: number;
  flagged: FlaggedCounts;
}

interface FlaggedCounts {
  total: number;
  conflicts: number;
  low_confidence: number;
  stale: number;
}
```

### Analytics Edge Function 

A single Supabase Edge Function (`analytics`) handles all analytics queries. It accepts a request type, time range, and user ID, then runs the appropriate aggregation queries and returns structured data matching the interfaces above.

```ts
// supabase/functions/analytics/index.ts
import { serve } from "https://deno.land/std/http/server.ts";
import { createClient } from "@supabase/supabase-js";

interface AnalyticsRequest {
  type: "overview" | "personas" | "models" | "memory";
  time_range: "7d" | "30d" | "90d" | "all";
}

const TIME_INTERVALS: Record<string, string> = {
  "7d": "7 days",
  "30d": "30 days",
  "90d": "90 days",
  "all": "100 years",
};

serve(async (req) => {
  // 1. Auth
  const authHeader = req.headers.get("Authorization");
  if (!authHeader) return new Response("Unauthorized", { status: 401 });

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );

  const anonClient = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: authHeader } } }
  );
  const { data: { user } } = await anonClient.auth.getUser();
  if (!user) return new Response("Unauthorized", { status: 401 });

  const body: AnalyticsRequest = await req.json();
  const interval = TIME_INTERVALS[body.time_range] || "30 days";
  const userId = user.id;

  let result;
  switch (body.type) {
    case "overview":
      result = await computeOverview(supabase, userId, interval);
      break;
    case "personas":
      result = await computePersonaPerformance(supabase, userId, interval);
      break;
    case "models":
      result = await computeModelUsage(supabase, userId, interval);
      break;
    case "memory":
      result = await computeMemoryHealth(supabase, userId);
      break;
    default:
      return new Response("Invalid type", { status: 400 });
  }

  return new Response(JSON.stringify(result), {
    headers: { "Content-Type": "application/json" },
  });
});
```

### v2: Materialized Views 

For v2, frequently-accessed aggregations will be pre-computed using PostgreSQL materialized views refreshed on a schedule:

```sql
-- v2: Refresh nightly via pg_cron
CREATE MATERIALIZED VIEW analytics_daily_summary AS
SELECT
  user_id,
  date_trunc('day', created_at)::date AS day,
  COUNT(*) AS conversations,
  SUM(message_count) AS total_messages,
  COUNT(DISTINCT persona_ids) AS active_personas
FROM chats
WHERE deleted_at IS NULL
GROUP BY user_id, date_trunc('day', created_at)::date;

CREATE UNIQUE INDEX ON analytics_daily_summary(user_id, day);

SELECT cron.schedule('refresh-analytics-daily', '0 3 * * *',
  'REFRESH MATERIALIZED VIEW CONCURRENTLY analytics_daily_summary');
```

This is future work. v1 uses live queries exclusively.

---

## 12.2 Edge Function: computeOverview 

This function aggregates all 8 overview metric cards plus the daily activity chart data and storage usage summary in a single handler.

```ts
async function computeOverview(
  supabase: SupabaseClient,
  userId: string,
  interval: string
): Promise<OverviewMetrics> {
  const [
    conversationStats,
    personaUtilization,
    memoryStats,
    topModel,
    mostActivePersona,
    avgLength,
    tokenStats,
    searchStats,
    dailyActivity,
    storageUsage,
  ] = await Promise.all([
    queryConversationStats(supabase, userId, interval),
    queryPersonaUtilization(supabase, userId, interval),
    queryMemoryStats(supabase, userId, interval),
    queryTopModel(supabase, userId, interval),
    queryMostActivePersona(supabase, userId, interval),
    queryAvgConversationLength(supabase, userId, interval),
    queryTokenStats(supabase, userId),
    querySearchStats(supabase, userId, interval),
    queryDailyActivity(supabase, userId, interval),
    queryStorageUsage(supabase, userId),
  ]);

  return {
    total_conversations: conversationStats,
    persona_utilization: personaUtilization,
    memories_created: memoryStats,
    top_model: topModel,
    most_active_persona: mostActivePersona,
    avg_conversation_length: avgLength,
    tokens_this_month: tokenStats,
    searches_performed: searchStats,
    daily_activity: dailyActivity,
    storage_usage: storageUsage,
  };
}
```

### Card 1: Total Conversations 

```sql
CREATE OR REPLACE FUNCTION analytics_conversation_stats(
  p_user_id UUID,
  p_interval TEXT
)
RETURNS TABLE(current_count BIGINT, prior_count BIGINT)
LANGUAGE sql STABLE AS $$
  WITH current_period AS (
    SELECT COUNT(*) AS cnt
    FROM chats
    WHERE user_id = p_user_id
      AND deleted_at IS NULL
      AND created_at > now() - p_interval::interval
  ),
  prior_period AS (
    SELECT COUNT(*) AS cnt
    FROM chats
    WHERE user_id = p_user_id
      AND deleted_at IS NULL
      AND created_at > now() - (p_interval::interval * 2)
      AND created_at <= now() - p_interval::interval
  )
  SELECT c.cnt AS current_count, p.cnt AS prior_count
  FROM current_period c, prior_period p;
$$;
```

```ts
async function queryConversationStats(supabase, userId, interval): Promise<MetricCard> {
  const { data } = await supabase.rpc("analytics_conversation_stats", {
    p_user_id: userId, p_interval: interval,
  });
  const current = data?.[0]?.current_count ?? 0;
  const prior = data?.[0]?.prior_count ?? 0;
  const trendPct = prior === 0 ? 0 : Math.round(((current - prior) / prior) * 100);
  return {
    value: current,
    trend: trendPct > 0 ? `+${trendPct}%` : trendPct < 0 ? `${trendPct}%` : "No change",
    trend_direction: trendPct > 0 ? "positive" : trendPct < 0 ? "negative" : "neutral",
  };
}
```

### Card 2: Persona Utilization 

```sql
CREATE OR REPLACE FUNCTION analytics_persona_utilization(
  p_user_id UUID,
  p_interval TEXT
)
RETURNS TABLE(active_personas BIGINT, total_personas BIGINT, prior_active BIGINT)
LANGUAGE sql STABLE AS $$
  SELECT
    (SELECT COUNT(DISTINCT cp.persona_id)
     FROM chat_participants cp
     JOIN chats c ON c.id = cp.chat_id
     WHERE c.user_id = p_user_id
       AND c.created_at > now() - p_interval::interval
       AND cp.persona_id IS NOT NULL
       AND c.deleted_at IS NULL
    ) AS active_personas,
    (SELECT COUNT(*) FROM personas
     WHERE user_id = p_user_id AND archived_at IS NULL
    ) AS total_personas,
    (SELECT COUNT(DISTINCT cp.persona_id)
     FROM chat_participants cp
     JOIN chats c ON c.id = cp.chat_id
     WHERE c.user_id = p_user_id
       AND c.created_at > now() - (p_interval::interval * 2)
       AND c.created_at <= now() - p_interval::interval
       AND cp.persona_id IS NOT NULL
       AND c.deleted_at IS NULL
    ) AS prior_active;
$$;
```

If `total_personas = 0`, value displays "—" and trend shows "Create your first Persona" in `textFaint`.

### Card 3: Memories Created 

```sql
CREATE OR REPLACE FUNCTION analytics_memory_created(
  p_user_id UUID, p_interval TEXT
)
RETURNS TABLE(new_memories BIGINT, this_week BIGINT)
LANGUAGE sql STABLE AS $$
  SELECT
    COUNT(*) FILTER (WHERE pm.created_at > now() - p_interval::interval) AS new_memories,
    COUNT(*) FILTER (WHERE pm.created_at > now() - interval '7 days') AS this_week
  FROM persona_memories pm
  JOIN personas p ON p.id = pm.persona_id
  WHERE p.user_id = p_user_id;
$$;
```

### Card 4: Top Model 

```sql
CREATE OR REPLACE FUNCTION analytics_top_model(
  p_user_id UUID, p_interval TEXT
)
RETURNS TABLE(model TEXT, call_count BIGINT, usage_pct INTEGER)
LANGUAGE sql STABLE AS $$
  SELECT
    m.model,
    COUNT(*) AS call_count,
    ROUND(COUNT(*)::numeric / NULLIF(SUM(COUNT(*)) OVER (), 0) * 100)::integer AS usage_pct
  FROM messages m
  WHERE m.user_id = p_user_id
    AND m.role = 'assistant'
    AND m.model IS NOT NULL
    AND m.created_at > now() - p_interval::interval
    AND m.deleted_at IS NULL
  GROUP BY m.model
  ORDER BY call_count DESC
  LIMIT 1;
$$;
```

Model display name mapping:

```ts
const MODEL_DISPLAY_NAMES: Record<string, string> = {
  "claude-sonnet-4-5-20250929": "Sonnet 4.5",
  "claude-opus-4-5-20250301": "Opus 4.5",
  "claude-haiku-4-5-20251001": "Haiku 4.5",
  "gpt-4o": "GPT-4o",
  "gpt-4o-mini": "GPT-4o Mini",
  "gemini-2.5-pro": "Gemini 2.5 Pro",
  "gemini-2.5-flash": "Gemini 2.5 Flash",
  "deepseek-r1": "DeepSeek R1",
  "dall-e-3": "DALL-E 3",
};
```

### Card 5: Most Active Persona 

```sql
CREATE OR REPLACE FUNCTION analytics_most_active_persona(
  p_user_id UUID, p_interval TEXT
)
RETURNS TABLE(name TEXT, conversation_count BIGINT)
LANGUAGE sql STABLE AS $$
  SELECT p.name, COUNT(DISTINCT cp.chat_id) AS conversation_count
  FROM personas p
  JOIN chat_participants cp ON cp.persona_id = p.id
  JOIN chats c ON c.id = cp.chat_id
  WHERE p.user_id = p_user_id
    AND c.created_at > now() - p_interval::interval
    AND c.deleted_at IS NULL
  GROUP BY p.id, p.name
  ORDER BY conversation_count DESC
  LIMIT 1;
$$;
```

### Card 6: Avg Conversation Length 

```sql
CREATE OR REPLACE FUNCTION analytics_avg_conversation_length(
  p_user_id UUID, p_interval TEXT
)
RETURNS TABLE(current_avg INTEGER, prior_avg INTEGER)
LANGUAGE sql STABLE AS $$
  WITH current_chats AS (
    SELECT c.id, COUNT(m.id) AS msg_count
    FROM chats c JOIN messages m ON m.chat_id = c.id
    WHERE c.user_id = p_user_id
      AND c.created_at > now() - p_interval::interval
      AND c.deleted_at IS NULL AND m.deleted_at IS NULL
    GROUP BY c.id
  ),
  prior_chats AS (
    SELECT c.id, COUNT(m.id) AS msg_count
    FROM chats c JOIN messages m ON m.chat_id = c.id
    WHERE c.user_id = p_user_id
      AND c.created_at > now() - (p_interval::interval * 2)
      AND c.created_at <= now() - p_interval::interval
      AND c.deleted_at IS NULL AND m.deleted_at IS NULL
    GROUP BY c.id
  )
  SELECT
    COALESCE((SELECT AVG(msg_count)::integer FROM current_chats), 0) AS current_avg,
    COALESCE((SELECT AVG(msg_count)::integer FROM prior_chats), 0) AS prior_avg;
$$;
```

### Card 7: Tokens This Month 

```sql
CREATE OR REPLACE FUNCTION analytics_token_stats(p_user_id UUID)
RETURNS TABLE(total_tokens BIGINT, has_byok BOOLEAN)
LANGUAGE sql STABLE AS $$
  SELECT
    COALESCE(SUM(
      COALESCE((metadata->>'prompt_tokens')::bigint, 0) +
      COALESCE((metadata->>'completion_tokens')::bigint, 0)
    ), 0) AS total_tokens,
    EXISTS (
      SELECT 1 FROM user_api_keys WHERE user_id = p_user_id AND active = true
    ) AS has_byok
  FROM messages
  WHERE user_id = p_user_id
    AND role = 'assistant'
    AND created_at > date_trunc('month', now())
    AND deleted_at IS NULL;
$$;
```

Token formatting:

```ts
function formatTokenCount(tokens: number): string {
  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
  if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`;
  return String(tokens);
}
```

### Card 8: Searches Performed 

```sql
CREATE OR REPLACE FUNCTION analytics_search_stats(
  p_user_id UUID, p_interval TEXT
)
RETURNS TABLE(total BIGINT, today BIGINT)
LANGUAGE sql STABLE AS $$
  SELECT
    COUNT(*) FILTER (WHERE created_at > now() - p_interval::interval) AS total,
    COUNT(*) FILTER (WHERE created_at > now() - interval '1 day') AS today
  FROM search_history
  WHERE user_id = p_user_id;
$$;
```

### Daily Activity Chart Data 

```sql
CREATE OR REPLACE FUNCTION analytics_daily_activity(
  p_user_id UUID, p_interval TEXT
)
RETURNS TABLE(day DATE, conversations BIGINT, messages BIGINT)
LANGUAGE sql STABLE AS $$
  SELECT
    d.day::date,
    COUNT(DISTINCT c.id) AS conversations,
    COUNT(DISTINCT m.id) AS messages
  FROM generate_series(
    (now() - p_interval::interval)::date,
    now()::date,
    '1 day'::interval
  ) d(day)
  LEFT JOIN chats c ON c.user_id = p_user_id
    AND c.created_at::date = d.day::date
    AND c.deleted_at IS NULL
  LEFT JOIN messages m ON m.chat_id = c.id
    AND m.deleted_at IS NULL
  GROUP BY d.day
  ORDER BY d.day ASC;
$$;
```

The `generate_series` ensures every day in the interval has a row, even if activity was zero (important for continuous chart rendering).

### Storage Usage Summary 

```sql
CREATE OR REPLACE FUNCTION analytics_storage_usage(p_user_id UUID)
RETURNS TABLE(
  used_bytes BIGINT, file_count BIGINT,
  ai_generated_count BIGINT, uploaded_count BIGINT
)
LANGUAGE sql STABLE AS $$
  SELECT
    COALESCE(SUM(size_bytes), 0) AS used_bytes,
    COUNT(*) AS file_count,
    COUNT(*) FILTER (WHERE origin = 'ai_generated') AS ai_generated_count,
    COUNT(*) FILTER (WHERE origin = 'uploaded') AS uploaded_count
  FROM files
  WHERE user_id = p_user_id AND deleted_at IS NULL;
$$;
```

---

## 12.3 Screen Layout 

### Container 

```ts
{
  padding: `${py}px ${px}px`,
  overflowY: "auto",
  height: "100%",
}

// Inner container
{
  maxWidth: 900,
  margin: "0 auto",
}
```

### Header 

```ts
{
  marginBottom: 24,
}
```

- Title: "Insights & Analytics" (22px, weight 300, `text`, letterSpacing \-0.03em)  
- Subtitle: "Understand how you and your Personas work together." (13px, weight 300, `textMuted`, marginTop 4px)

### Tab Navigation \+ Controls Row 

The tab pills and time range selector sit on the same horizontal line:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  marginBottom: 24,
  flexWrap: "wrap",
}
```

**Tab pills** (4 tabs):

| ID | Label |
| :---- | :---- |
| `overview` | Overview |
| `personas` | Persona Performance |
| `models` | Model Usage |
| `memory` | Memory Health |

Each pill:

```ts
{
  padding: "6px 14px",
  borderRadius: 20,
  fontSize: 11,
  cursor: "pointer",
  fontWeight: 400,
  border: "none",
  background: isActive ? theme.accent : theme.surfaceAlt,
  color: isActive ? theme.accentText : theme.textMuted,
  transition: "background 0.15s ease, color 0.15s ease",
  display: "flex",
  alignItems: "center",
  gap: 4,
}
```

Locked tabs show a Lock icon (8px, `textFaint`) next to the label. Clicking a locked tab shows the tier gate content (Section 12.10).

**Spacer:** `flex: 1` between the tab pills and the right-side controls.

**Time Range Selector** (Premium and Pro only):

```ts
{
  display: "flex",
  gap: 4,
}
```

4 options: `7d`, `30d`, `90d`, `All`. Each:

```ts
{
  padding: "4px 10px",
  borderRadius: 6,
  fontSize: 10,
  border: isActive ? "none" : `1px solid ${theme.border}`,
  background: isActive ? theme.surfaceAlt : "none",
  color: isActive ? theme.text : theme.textFaint,
  cursor: "pointer",
  fontWeight: isActive ? 450 : 350,
}
```

Default: `30d`. Changing the time range re-fetches all metrics on the current tab. For Free/Plus users, this control is hidden — all metrics default to 30d.

**Export Button** (Premium and Pro only):

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 4,
  padding: "4px 10px",
  borderRadius: 6,
  fontSize: 10,
  border: `1px solid ${theme.border}`,
  background: "none",
  color: theme.textMuted,
  cursor: "pointer",
  marginLeft: 8,
}
```

Download icon (10px) \+ "Export". See Section 12.8 for the full export system.

### Last Updated Indicator 

Below the tab row:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 6,
  marginBottom: 16,
}
```

- Timestamp: "Last updated 2 minutes ago" (9px, weight 300, `textFaint`). Displays "Updating..." when fetching.  
- Refresh button: RefreshCw icon (10px, `textFaint`, background none, border none, cursor pointer, padding 2px). Spins while fetching.

```ts
function formatLastUpdated(dataUpdatedAt: number): string {
  const diff = Date.now() - dataUpdatedAt;
  const minutes = Math.floor(diff / 60000);
  if (minutes < 1) return "Just now";
  if (minutes === 1) return "1 minute ago";
  if (minutes < 60) return `${minutes} minutes ago`;
  const hours = Math.floor(minutes / 60);
  return `${hours} hour${hours > 1 ? "s" : ""} ago`;
}
```

---

## 12.4 Overview Tab 

The landing tab. Shows 8 metric cards in a responsive grid, an activity trend chart, and a storage usage summary.

### Grid Layout 

```ts
{
  display: "grid",
  gridTemplateColumns: mobile ? "1fr 1fr" : "1fr 1fr 1fr 1fr",
  gap: 12,
}
```

On mobile: 2 columns x 4 rows. On desktop: 4 columns x 2 rows.

### Metric Card Component 

Each card follows a consistent three-layer structure: label, value, trend.

```ts
// Card container
{
  padding: 16,
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 10,
}
```

**Label:**

```ts
{
  fontSize: 10,
  color: theme.textMuted,
  fontWeight: 400,
  marginBottom: 8,
  textTransform: "uppercase",
  letterSpacing: "0.05em",
}
```

**Value:**

```ts
{
  fontSize: 26,
  fontWeight: 200,
  color: theme.text,
  letterSpacing: "-0.03em",
  lineHeight: 1,
}
```

**Trend:**

```ts
{
  fontSize: 10,
  fontWeight: 400,
  marginTop: 6,
}
```

Trend color logic:

| Direction | Color | Example |
| :---- | :---- | :---- |
| Positive | `theme.dot.active` (green) | "+12%", "+42 this week" |
| Negative | `#ef4444` (red) | "-3% vs last week" |
| Neutral | `theme.textFaint` | "62% of calls", "via BYOK" |

### 8 Overview Metrics Summary 

| \# | Label | Value | Trend |
| :---- | :---- | :---- | :---- |
| 1 | TOTAL CONVERSATIONS | Count in period | "±N% vs prior period" |
| 2 | PERSONA UTILIZATION | Active/total % | "±N% pts vs prior" |
| 3 | MEMORIES CREATED | Count in period | "+N this week" |
| 4 | TOP MODEL | Display name | "N% of calls" |
| 5 | MOST ACTIVE PERSONA | Persona name | "N conversations" |
| 6 | AVG CONVERSATION LENGTH | "N msgs" | "±N% vs prior" |
| 7 | TOKENS THIS MONTH | "340K" / "1.2M" | "via BYOK" or "platform" |
| 8 | SEARCHES PERFORMED | Count in period | "+N today" |

### Free-Tier Card Visibility 

Free-tier users see only the first 4 cards. Grid: `gridTemplateColumns: "1fr 1fr"` on both mobile and desktop. Plus users see all 8 cards.

### Activity Trend Chart (Plus and Above) 

Below the metric grid, an SVG area chart shows daily conversation and message volume.

```ts
{
  marginTop: 20,
  padding: 20,
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 12,
}
```

**Chart header:**

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  marginBottom: 16,
}
```

- Title: "Activity" (13px, weight 450, `text`)  
- Legend (flex, gap 16px):  
  - Small circle (6px, accent bg) \+ "Conversations" (10px, `textMuted`)  
  - Small circle (6px, `rgba(46,149,243,0.4)` bg) \+ "Messages" (10px, `textMuted`)

**Chart area:**

```ts
{
  height: 140,
  position: "relative",
}
```

Two SVG layers:

1. **Messages** — fill `rgba(46,149,243,0.08)`, stroke `rgba(46,149,243,0.3)` 1px  
2. **Conversations** — fill `rgba(46,149,243,0.15)`, stroke `theme.accent` 1.5px (foreground)

```ts
function ActivityChart({ data, width, height }: {
  data: DailyActivityPoint[];
  width: number;
  height: number;
}) {
  if (!data.length) return null;

  const maxConv = Math.max(...data.map(d => d.conversations), 1);
  const maxMsg = Math.max(...data.map(d => d.messages), 1);
  const xStep = width / Math.max(data.length - 1, 1);

  function buildPath(values: number[], max: number): string {
    return values.map((v, i) => {
      const x = i * xStep;
      const y = height - (v / max) * (height - 10);
      return `${i === 0 ? "M" : "L"} ${x} ${y}`;
    }).join(" ");
  }

  function buildArea(values: number[], max: number): string {
    const line = buildPath(values, max);
    return `${line} L ${(values.length - 1) * xStep} ${height} L 0 ${height} Z`;
  }

  return (
    <svg width={width} height={height} style={{ overflow: "visible" }}>
      <path d={buildArea(data.map(d => d.messages), maxMsg)}
            fill="rgba(46,149,243,0.08)" />
      <path d={buildPath(data.map(d => d.messages), maxMsg)}
            fill="none" stroke="rgba(46,149,243,0.3)" strokeWidth="1" />
      <path d={buildArea(data.map(d => d.conversations), maxConv)}
            fill="rgba(46,149,243,0.15)" />
      <path d={buildPath(data.map(d => d.conversations), maxConv)}
            fill="none" stroke={theme.accent} strokeWidth="1.5" />
    </svg>
  );
}
```

**X-axis labels:** 5-7 evenly spaced dates (10px, weight 300, `textFaint`). Format: "Feb 1", "Feb 7".

**Hover tooltip:**

```ts
{
  position: "absolute",
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  padding: "8px 12px",
  boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
  pointerEvents: "none",
  zIndex: 5,
}
```

- Date: "Feb 13" (11px, weight 450, `text`)  
- "{n} conversations" (10px, `textMuted`)  
- "{n} messages" (10px, `textFaint`)

Free-tier users do not see the activity chart.

### Storage Usage Bar (All Tiers) 

Below the activity chart (or below the metric grid for Free):

```ts
{
  marginTop: 20,
  padding: 16,
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 10,
  display: "flex",
  alignItems: "center",
  gap: 16,
}
```

- HardDrive icon (14px, `textMuted`) \+ "Storage" (12px, weight 400, `text`)  
- Progress bar (flex: 1, height 4px, borderRadius 2). Fill color: accent (normal), amber (\#f59e0b) at \>75%, red (\#ef4444) at \>90%.  
- "{used} / {total}" (11px, weight 350, `textMuted`) \+ "{n} files" (10px, weight 300, `textFaint`)

### Data Fetching 

```ts
const { data: overview, isLoading, isFetching, dataUpdatedAt } = useQuery({
  queryKey: ["analytics", "overview", timeRange],
  queryFn: async () => {
    const supabase = createClient();
    const { data, error } = await supabase.functions.invoke("analytics", {
      body: { type: "overview", time_range: timeRange },
    });
    if (error) throw error;
    return data as OverviewMetrics;
  },
  staleTime: 5 * 60 * 1000,
  refetchOnWindowFocus: false,
});
```

### Loading State 

Skeleton cards in grid (pulse animation 1.5s):

- Label: 60x8px bar  
- Value: 80x20px bar (marginTop 8px)  
- Trend: 50x8px bar (marginTop 6px)

Activity chart: single skeleton rectangle (full width x 140px). Storage bar: skeleton rectangle (full width x 4px).

---

## 12.5 Persona Performance Tab 

Shows a per-persona breakdown of activity, memories, decisions, and skills.

### Edge Function: computePersonaPerformance 

```ts
async function computePersonaPerformance(
  supabase: SupabaseClient, userId: string, interval: string
): Promise<PersonaPerformanceData> {
  const { data: personas } = await supabase
    .from("personas")
    .select("id, name, role, status, skill_ceiling")
    .eq("user_id", userId)
    .is("archived_at", null)
    .order("updated_at", { ascending: false });

  if (!personas?.length) return { personas: [] };
  const personaIds = personas.map(p => p.id);

  const [memoryCounts, skillCounts, activityCounts, lastActiveTimes] = await Promise.all([
    supabase.rpc("persona_memory_stats", { persona_ids: personaIds }),
    supabase.rpc("persona_skill_counts", { persona_ids: personaIds }),
    supabase.rpc("persona_activity_counts", {
      persona_ids: personaIds, p_interval: interval,
    }),
    supabase.rpc("persona_last_active", { persona_ids: personaIds }),
  ]);

  return {
    personas: personas.map(p => {
      const mem = memoryCounts.data?.find(m => m.persona_id === p.id);
      const skills = skillCounts.data?.find(s => s.persona_id === p.id);
      const activity = activityCounts.data?.find(a => a.persona_id === p.id);
      const active = lastActiveTimes.data?.find(a => a.persona_id === p.id);
      return {
        id: p.id, name: p.name, role: p.role, status: p.status,
        memory_count: mem?.total ?? 0, decision_count: mem?.decisions ?? 0,
        skill_count: skills?.count ?? 0, skill_ceiling: p.skill_ceiling,
        conversation_count: activity?.conversations ?? 0,
        message_count: activity?.messages ?? 0,
        last_active_at: active?.last_active_at ?? null,
      };
    }),
  };
}
```

### Supporting RPC Functions 

```sql
CREATE OR REPLACE FUNCTION persona_memory_stats(persona_ids UUID[])
RETURNS TABLE(persona_id UUID, total BIGINT, decisions BIGINT)
LANGUAGE sql STABLE AS $$
  SELECT pm.persona_id,
    COUNT(*) FILTER (WHERE active = true) AS total,
    COUNT(*) FILTER (WHERE type = 'decision' AND active = true) AS decisions
  FROM persona_memories pm
  WHERE pm.persona_id = ANY(persona_ids)
  GROUP BY pm.persona_id;
$$;

CREATE OR REPLACE FUNCTION persona_skill_counts(persona_ids UUID[])
RETURNS TABLE(persona_id UUID, count BIGINT)
LANGUAGE sql STABLE AS $$
  SELECT persona_id, COUNT(*)
  FROM persona_skills
  WHERE persona_id = ANY(persona_ids)
  GROUP BY persona_id;
$$;

CREATE OR REPLACE FUNCTION persona_activity_counts(
  persona_ids UUID[], p_interval TEXT
)
RETURNS TABLE(persona_id UUID, conversations BIGINT, messages BIGINT)
LANGUAGE sql STABLE AS $$
  SELECT cp.persona_id,
    COUNT(DISTINCT cp.chat_id) AS conversations,
    COUNT(DISTINCT m.id) AS messages
  FROM chat_participants cp
  JOIN chats c ON c.id = cp.chat_id
  LEFT JOIN messages m ON m.chat_id = c.id AND m.role = 'assistant'
    AND m.deleted_at IS NULL
  WHERE cp.persona_id = ANY(persona_ids)
    AND c.deleted_at IS NULL
    AND c.created_at > now() - p_interval::interval
  GROUP BY cp.persona_id;
$$;

CREATE OR REPLACE FUNCTION persona_last_active(persona_ids UUID[])
RETURNS TABLE(persona_id UUID, last_active_at TIMESTAMPTZ)
LANGUAGE sql STABLE AS $$
  SELECT cp.persona_id, MAX(m.created_at) AS last_active_at
  FROM chat_participants cp
  JOIN messages m ON m.chat_id = cp.chat_id AND m.role = 'assistant'
  WHERE cp.persona_id = ANY(persona_ids)
  GROUP BY cp.persona_id;
$$;
```

### Persona Cards 

Vertical list (gap 10px). Each card:

```ts
{
  padding: "14px 18px",
  background: theme.surface,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 12,
  display: "flex",
  alignItems: "center",
  gap: 14,
  cursor: "pointer",
}
```

**Left:** 36px avatar circle.

**Center (flex: 1):**

- Name: `{name}` (13px, weight 450\) \+  `· {role}` (weight 300, `textMuted`)  
- Stats row (marginTop 6px, flex, gap 20px, 11px, flexWrap wrap):

| Stat | Pattern | Example |
| :---- | :---- | :---- |
| Memories | **{n}** memories | **42** memories |
| Decisions | **{n}** decisions | **12** decisions |
| Skills | **{n}/{ceiling}** skills | **5/8** skills |
| Conversations | **{n}** chats | **18** chats |

Each stat: value in weight 450 `text`, label in weight 350 `textMuted`.

**Right (flexShrink 0, textAlign right):**

- StatusDot (6px)  
- Last active: relative time (10px, `textFaint`, marginTop 4px)

```ts
function relativeTime(date: string | null): string {
  if (!date) return "Never";
  const diff = Date.now() - new Date(date).getTime();
  const minutes = Math.floor(diff / 60000);
  if (minutes < 1) return "Just now";
  if (minutes < 60) return `${minutes} min ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours} hr${hours > 1 ? "s" : ""} ago`;
  const days = Math.floor(hours / 24);
  return `${days} day${days > 1 ? "s" : ""} ago`;
}
```

### Sort Controls 

Above the list:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 6,
  marginBottom: 12,
}
```

- "Sort by:" (11px, weight 400, `textMuted`)  
- `<select>` dropdown (11px, `surfaceAlt` bg, border, borderRadius 6, padding 3px 8px)

| Value | Label | Sort Logic |
| :---- | :---- | :---- |
| `last_active` | "Last active" (default) | Most recent first, nulls last |
| `name` | "Name" | Alphabetical A-Z |
| `memories` | "Memories" | Descending |
| `conversations` | "Conversations" | Descending |
| `skills` | "Skills" | Descending |

### Click Behavior 

Clicking a persona card navigates to the Persona Detail View (Part 9), Overview tab.

### Loading State 

3 skeleton cards: 36px circle \+ 120x12px name bar \+ 200x10px stats bar \+ 6px circle (right).

### Empty State 

- Users icon (28px, `textFaint`, marginBottom 12px)  
- "No personas yet" (14px, weight 400, `text`)  
- "Create a Persona to start tracking their performance." (12px, weight 300, `textMuted`, marginTop 6px)  
- "Create Persona" button (accent bg, 12px, marginTop 16px). Navigates to People screen.

---

## 12.6 Model Usage Tab 

Shows which AI models are used, how often, and at what estimated cost.

### Edge Function: computeModelUsage 

```ts
async function computeModelUsage(
  supabase: SupabaseClient, userId: string, interval: string
): Promise<ModelUsageData> {
  const { data: rows } = await supabase.rpc("analytics_model_usage", {
    p_user_id: userId, p_interval: interval,
  });
  const { data: byokCheck } = await supabase
    .from("user_api_keys").select("id")
    .eq("user_id", userId).eq("active", true).limit(1);

  const models: ModelUsageEntry[] = (rows ?? []).map(row => {
    const displayName = MODEL_DISPLAY_NAMES[row.model]
      || row.model.split("/").pop() || row.model;
    const cost = estimateCost(row.model, row.prompt_tokens, row.completion_tokens);
    return {
      model: row.model, display_name: displayName,
      provider: inferProvider(row.model),
      call_count: row.call_count, usage_pct: row.usage_pct,
      prompt_tokens: row.prompt_tokens,
      completion_tokens: row.completion_tokens,
      estimated_cost: cost,
    };
  });

  return {
    models,
    total_tokens: models.reduce((s, m) => s + m.prompt_tokens + m.completion_tokens, 0),
    total_estimated_cost: models.reduce((s, m) => s + m.estimated_cost, 0),
    byok_active: (byokCheck?.length ?? 0) > 0,
  };
}
```

```sql
CREATE OR REPLACE FUNCTION analytics_model_usage(
  p_user_id UUID, p_interval TEXT
)
RETURNS TABLE(
  model TEXT, call_count BIGINT, usage_pct INTEGER,
  prompt_tokens BIGINT, completion_tokens BIGINT
)
LANGUAGE sql STABLE AS $$
  SELECT m.model, COUNT(*) AS call_count,
    ROUND(COUNT(*)::numeric / NULLIF(SUM(COUNT(*)) OVER (), 0) * 100)::integer AS usage_pct,
    COALESCE(SUM((m.metadata->>'prompt_tokens')::bigint), 0) AS prompt_tokens,
    COALESCE(SUM((m.metadata->>'completion_tokens')::bigint), 0) AS completion_tokens
  FROM messages m
  WHERE m.user_id = p_user_id AND m.role = 'assistant'
    AND m.model IS NOT NULL
    AND m.created_at > now() - p_interval::interval
    AND m.deleted_at IS NULL
  GROUP BY m.model
  ORDER BY call_count DESC;
$$;
```

### Provider Inference & Cost Estimation 

```ts
function inferProvider(model: string): string {
  if (model.includes("claude")) return "Anthropic";
  if (model.includes("gpt") || model.includes("dall-e")) return "OpenAI";
  if (model.includes("gemini")) return "Google";
  if (model.includes("deepseek")) return "DeepSeek";
  return "OpenRouter";
}

const MODEL_PRICING: Record<string, { prompt: number; completion: number }> = {
  "claude-sonnet-4-5-20250929": { prompt: 3.0, completion: 15.0 },
  "claude-opus-4-5-20250301": { prompt: 15.0, completion: 75.0 },
  "claude-haiku-4-5-20251001": { prompt: 0.80, completion: 4.0 },
  "gpt-4o": { prompt: 2.5, completion: 10.0 },
  "gpt-4o-mini": { prompt: 0.15, completion: 0.60 },
  "gemini-2.5-pro": { prompt: 1.25, completion: 5.0 },
  "deepseek-r1": { prompt: 0.55, completion: 2.19 },
};

function estimateCost(model: string, prompt: number, completion: number): number {
  const p = MODEL_PRICING[model];
  if (!p) return 0;
  return (prompt / 1_000_000) * p.prompt + (completion / 1_000_000) * p.completion;
}

function formatCost(cost: number): string {
  if (cost === 0) return "—";
  if (cost < 0.01) return "<$0.01";
  return `$${cost.toFixed(2)}`;
}
```

### Model Row Layout 

Each model row (padding 12px 18px, surface bg, borderSubtle border, borderRadius 10, flex, gap 14px):

**Left:** Usage percentage (width 44, textAlign right, 20px, weight 200, `text`)

**Center (flex: 1):**

- Model name (13px, weight 400\) \+  `· {provider}` (weight 300, `textFaint`, 10px)  
- Progress bar (height 3px, borderRadius 2, `surfaceAlt` track, `accent` fill at `{pct}%`)

**Right (flexShrink 0, textAlign right):**

- "{n} calls" (11px, weight 400, `text`)  
- "\~{cost}" (10px, weight 350, `textFaint`)

### Total Summary Row 

Below model list (borderTop, padding 12px 0, flex, justifyContent space-between):

- "Total" (12px, weight 450, `text`)  
- "{totalTokens} tokens · \~{totalCost}" (12px, weight 350, `textMuted`)

### BYOK Indicator 

If `byok_active: true`, badge below tab title:

```ts
{
  display: "inline-flex",
  alignItems: "center",
  gap: 4,
  padding: "3px 8px",
  background: theme.surfaceAlt,
  borderRadius: 6,
  fontSize: 10,
  color: theme.textMuted,
  marginBottom: 12,
}
```

Key icon (10px) \+ "BYOK active — costs billed by your provider"

### Cost Disclaimer 

Below the total row (10px, weight 300, `textFaint`, textAlign center, lineHeight 1.5):

"Cost estimates are approximate and based on published model pricing. Actual costs depend on your API provider and plan. BYOK usage is billed directly by your provider."

### Model Color Coding (v2) 

| Model Family | Color |
| :---- | :---- |
| Claude | `#2e95f3` |
| GPT | `#10a37f` |
| Gemini | `#4285f4` |
| DALL-E | `#e94560` |
| DeepSeek | `#5b6ef5` |
| Other | `textMuted` |

v1: all bars use `theme.accent`.

### Empty State 

- BarChart3 icon (28px, `textFaint`, marginBottom 12px)  
- "No model usage data" (14px, weight 400, `text`)  
- "Start chatting with Personas to see which models they use." (12px, weight 300, `textMuted`)

---

## 12.7 Memory Health Tab 

Shows the health and composition of the user's total memory footprint across all personas.

### Edge Function: computeMemoryHealth 

```ts
async function computeMemoryHealth(
  supabase: SupabaseClient, userId: string
): Promise<MemoryHealthData> {
  const { data: stats } = await supabase.rpc("analytics_memory_health", {
    p_user_id: userId,
  });
  const { data: personaCount } = await supabase
    .from("personas").select("id", { count: "exact", head: true })
    .eq("user_id", userId).is("archived_at", null);

  const s = stats?.[0] ?? {};
  const flaggedTotal = (s.low_confidence ?? 0) + (s.stale ?? 0) + (s.conflicts ?? 0);

  return {
    total: s.total ?? 0, decisions: s.decisions ?? 0,
    decisions_this_week: s.decisions_this_week ?? 0,
    facts: s.facts ?? 0, preferences: s.preferences ?? 0,
    preferences_updated_recently: s.preferences_updated_recently ?? 0,
    skills: s.skills ?? 0,
    active_persona_count: personaCount?.length ?? 0,
    flagged: {
      total: flaggedTotal, conflicts: s.conflicts ?? 0,
      low_confidence: s.low_confidence ?? 0, stale: s.stale ?? 0,
    },
  };
}
```

```sql
CREATE OR REPLACE FUNCTION analytics_memory_health(p_user_id UUID)
RETURNS TABLE(
  total BIGINT, decisions BIGINT, decisions_this_week BIGINT,
  facts BIGINT, preferences BIGINT, preferences_updated_recently BIGINT,
  skills BIGINT, low_confidence BIGINT, stale BIGINT, conflicts BIGINT
)
LANGUAGE sql STABLE AS $$
  SELECT
    COUNT(*) FILTER (WHERE active = true) AS total,
    COUNT(*) FILTER (WHERE type = 'decision' AND active = true) AS decisions,
    COUNT(*) FILTER (WHERE type = 'decision' AND active = true
      AND pm.created_at > now() - interval '7 days') AS decisions_this_week,
    COUNT(*) FILTER (WHERE type = 'fact' AND active = true) AS facts,
    COUNT(*) FILTER (WHERE type = 'preference' AND active = true) AS preferences,
    COUNT(*) FILTER (WHERE type = 'preference' AND active = true
      AND pm.updated_at > now() - interval '7 days') AS preferences_updated_recently,
    COUNT(*) FILTER (WHERE type = 'skill' AND active = true) AS skills,
    COUNT(*) FILTER (WHERE confidence = 'weak' AND active = true) AS low_confidence,
    COUNT(*) FILTER (WHERE active = true
      AND pm.updated_at < now() - interval '90 days') AS stale,
    COUNT(*) FILTER (WHERE active = true
      AND pm.tags @> ARRAY['conflict']) AS conflicts
  FROM persona_memories pm
  JOIN personas p ON p.id = pm.persona_id
  WHERE p.user_id = p_user_id;
$$;
```

### Memory Stats Grid 

2-column grid (1-column on mobile), gap 12px. 6 cards with label/value/subtitle structure (same styling as Overview cards but with 28px value size).

| \# | Label | Value | Subtitle |
| :---- | :---- | :---- | :---- |
| 1 | TOTAL MEMORIES | `total` | "Across all Personas" |
| 2 | DECISIONS | `decisions` | "{decisions\_this\_week} this week" |
| 3 | FACTS | `facts` | "Most stable category" or "Growing steadily" |
| 4 | PREFERENCES | `preferences` | "{preferences\_updated\_recently} updated recently" |
| 5 | SKILLS LEARNED | `skills` | "Avg {n} per Persona" |
| 6 | FLAGGED FOR REVIEW | `flagged.total` | "{conflicts} conflicts detected" or "No issues detected" |

### Flagged Card Treatment 

Red-tinted border when `flaggedCount > 0`: `border: 1px solid rgba(239,68,68,0.3)`. Clickable → opens Flagged Review Modal.

### Flagged Review Modal 

Modal: surface bg, borderRadius 16, padding 24, width 480 (90% on mobile), maxHeight 70vh, overflowY auto.

**Header:** "Flagged Memories" (16px, weight 400\) \+ "{n} items" (12px, `textMuted`) \+ X close button.

**Filter chips:** All, Conflicts, Low Confidence, Stale.

**Each flagged item:**

Top row: Flag badge \+ persona name.

Badge colors:

- Conflict: `rgba(239,68,68,0.1)` bg, `#ef4444` text  
- Low Confidence: `rgba(245,158,11,0.1)` bg, `#f59e0b` text  
- Stale: `rgba(156,163,175,0.1)` bg, `textMuted` text

Memory content: 12px, weight 350, `textSec`, truncated to 120 chars.

Actions: "Review" (bordered, navigates to persona Memory tab) \+ "Dismiss" (borderless, `textFaint`).

**Conflict resolution panel** (expands on "Dismiss" for conflicts): Shows conflicting memory content \+ 3 buttons: "Keep this one", "Keep the other", "Keep both".

### Flagged Memory Logic 

| Flag | Condition | Badge |
| :---- | :---- | :---- |
| Conflict | `tags @> ARRAY['conflict']` | Red |
| Low Confidence | `confidence = 'weak'` | Amber |
| Stale | `updated_at < 90 days ago` AND `active = true` | Gray |

### Flagged Data Query 

```ts
const { data: flaggedMemories } = useQuery({
  queryKey: ["flagged-memories"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("persona_memories")
      .select(`id, persona_id, type, content, confidence, tags,
        created_at, updated_at, persona:personas(id, name)`)
      .eq("active", true)
      .or(`confidence.eq.weak,updated_at.lt.${ninetyDaysAgo},tags.cs.{conflict}`)
      .order("created_at", { ascending: false })
      .limit(50);
    return data ?? [];
  },
  enabled: flaggedModalOpen,
});
```

### Empty State 

- Brain icon (28px, `textFaint`)  
- "No memories yet"  
- "As your Personas chat and learn, their memories will be tracked here."

---

## 12.8 Export Analytics (Premium+) 

### Export Dropdown 

2 options: "Export as CSV" (FileSpreadsheet icon) \+ "Export as JSON" (FileJson icon).

```ts
{
  position: "absolute",
  top: "100%",
  right: 0,
  marginTop: 4,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  padding: 4,
  minWidth: 160,
  boxShadow: "0 4px 12px rgba(0,0,0,0.12)",
  zIndex: 40,
}
```

### Implementation 

```ts
async function exportAnalytics(
  tab: "overview" | "personas" | "models" | "memory",
  format: "csv" | "json",
  timeRange: string,
) {
  const supabase = createClient();
  const { data, error } = await supabase.functions.invoke("analytics-export", {
    body: { tab, time_range: timeRange, format },
  });
  if (error) { showToast("Export failed. Please try again."); return; }

  const mimeType = format === "csv" ? "text/csv" : "application/json";
  const blob = new Blob([data], { type: mimeType });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `aiconnected-${tab}-${timeRange}-${new Date().toISOString().split("T")[0]}.${format}`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}
```

### CSV Format Per Tab 

**Overview:** `metric_name, value, trend, trend_direction`

**Persona Performance:** `persona_name, role, status, memory_count, decision_count, skill_count, skill_ceiling, conversation_count, message_count, last_active_at`

**Model Usage:** `model_name, provider, usage_pct, call_count, prompt_tokens, completion_tokens, estimated_cost`

**Memory Health:** `type, count, flagged_count, notes`

---

## 12.9 Empty States 

### No Data (New User) 

- BarChart3 icon (32px, `textFaint`)  
- "No data yet" (16px, weight 400, `text`)  
- "Start chatting with your Personas to see analytics here." (13px, weight 300, `textMuted`)  
- "Go to Chat" button (accent bg, marginTop 20px). Navigates to Chat screen.

### Partial Data 

Individual cards show: Value "—" (em-dash, 26px, weight 200, `textFaint`) \+ Trend "No data for this period" (10px, `textFaint`). Card structure remains fully rendered.

---

## 12.10 Tier Feature Matrix 

| Feature | Free | Plus | Premium | Pro |
| :---- | :---- | :---- | :---- | :---- |
| Overview tab (4 cards) | ✓ | ✓ | ✓ | ✓ |
| Overview tab (all 8 cards) | — | ✓ | ✓ | ✓ |
| Activity trend chart | — | ✓ | ✓ | ✓ |
| Storage usage bar | ✓ | ✓ | ✓ | ✓ |
| Persona Performance tab | ✓ | ✓ | ✓ | ✓ |
| Model Usage tab | — | ✓ | ✓ | ✓ |
| Memory Health tab | — | ✓ | ✓ | ✓ |
| Time range selector | — | — | ✓ | ✓ |
| Export (CSV/JSON) | — | — | ✓ | ✓ |
| Flagged memory review | — | ✓ | ✓ | ✓ |

### Gated Tab Content 

Locked tab shows: Lock icon (20px) \+ "Unlock {TabName} insights" (14px) \+ description (12px, `textMuted`) \+ "Upgrade to Plus" button (accent bg).

---

## 12.11 Refresh & Caching 

### React Query Configuration 

```ts
const ANALYTICS_STALE_TIME = 5 * 60 * 1000;

const { data, isLoading, isFetching, dataUpdatedAt } = useQuery({
  queryKey: ["analytics", tab, timeRange],
  queryFn: fetchFn,
  staleTime: ANALYTICS_STALE_TIME,
  refetchOnWindowFocus: false,
});
```

Manual refresh: `queryClient.invalidateQueries({ queryKey: ["analytics", tab, timeRange] })`.

Tab switching: cached \+ fresh → instant render. Stale/missing → skeleton \+ background fetch. Time range persists across tab switches.

### Error State 

AlertCircle icon (24px) \+ "Failed to load analytics" \+ "Something went wrong. Please try again." \+ "Retry" button calling `refetch()`.

---

## 12.12 Accessibility & Responsive 

### Responsive Behavior 

| Viewport | Overview Grid | Memory Grid | Model List |
| :---- | :---- | :---- | :---- |
| Desktop (≥768px) | 4 columns | 2 columns | Full-width rows |
| Mobile (\<768px) | 2 columns | 1 column | Stacked layout |

Mobile model row: stacks vertically (name \+ pct top, bar middle, calls \+ cost bottom).

Mobile persona card: stats wrap to 2 rows via `flexWrap: "wrap", gap: "4px 16px"`.

Mobile chart: height 100px, 3-4 x-axis labels.

### Keyboard Navigation 

Tab pills focusable (tabIndex 0, Enter/Space activates). Time range, export, sort, refresh all keyboard-accessible. Flagged Review Modal traps focus while open.

---

*End of Part 12\. Proceed to Part 13: Settings Screen.*

# PART 13: SETTINGS SCREEN 

---

Settings is the user's control panel for platform configuration. It houses account management, model routing, API key management, instance type templates, the settings cascade hierarchy, and learned behavioral rules. The screen is organized into 6 tabs. All settings changes are persisted immediately (no global "Save" button) unless a specific flow requires confirmation (e.g., deleting an API key, changing tier). Settings changes propagate in real time to all active screens via React Query cache invalidation.

This screen surfaces configuration that exists across multiple tables documented in Parts 2, 5, 7, and this part. No settings are duplicated — each setting lives in exactly one authoritative location. This part introduces 3 new tables (`model_role_assignments`, `instance_types`, `instruction_memory`) and references existing tables (`profiles`, `api_keys`, `subscriptions`, `credit_transactions`, `instances`).

---

## 13.1 Data Architecture 

### New Table: model\_role\_assignments 

Model role assignments define which AI model handles each functional role (e.g., Research, Writing, Coding). Each role has a primary model and a fallback. Cipher consults these assignments when routing messages (Part 14).

```sql
CREATE TABLE public.model_role_assignments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  role TEXT NOT NULL,
  primary_model TEXT NOT NULL,
  fallback_model TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(user_id, role)
);

ALTER TABLE public.model_role_assignments ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own role assignments"
  ON public.model_role_assignments FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_model_roles_user ON public.model_role_assignments(user_id);

CREATE TRIGGER set_model_roles_updated_at
  BEFORE UPDATE ON public.model_role_assignments
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

TypeScript interface:

```ts
interface ModelRoleAssignment {
  id: string;
  user_id: string;
  role: string;
  primary_model: string;
  fallback_model: string | null;
  created_at: string;
  updated_at: string;
}
```

**Default roles** seeded on account creation:

| Role | Description | Default Primary | Default Fallback |
| :---- | :---- | :---- | :---- |
| research | Deep analysis and information gathering | claude-sonnet-4-5-20250929 | gpt-4o |
| writing | Prose, copy, long-form content | claude-sonnet-4-5-20250929 | claude-haiku-4-5-20251001 |
| coding | Code generation, debugging, architecture | claude-sonnet-4-5-20250929 | gpt-4o |
| creative | Design prompts, brainstorming, image generation | dall-e-3 | claude-sonnet-4-5-20250929 |
| planning | Project plans, strategy, task breakdown | claude-opus-4-5-20250301 | claude-sonnet-4-5-20250929 |
| reasoning | Complex logic, math, multi-step analysis | claude-opus-4-5-20250301 | deepseek-r1 |
| quick | Simple questions, lookups, translations | claude-haiku-4-5-20251001 | gpt-4o-mini |

Seeding function (runs inside `handle_new_user` trigger or as a separate trigger on `profiles` insert):

```sql
CREATE OR REPLACE FUNCTION public.seed_model_roles()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.model_role_assignments (user_id, role, primary_model, fallback_model)
  VALUES
    (NEW.id, 'research', 'claude-sonnet-4-5-20250929', 'gpt-4o'),
    (NEW.id, 'writing', 'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001'),
    (NEW.id, 'coding', 'claude-sonnet-4-5-20250929', 'gpt-4o'),
    (NEW.id, 'creative', 'dall-e-3', 'claude-sonnet-4-5-20250929'),
    (NEW.id, 'planning', 'claude-opus-4-5-20250301', 'claude-sonnet-4-5-20250929'),
    (NEW.id, 'reasoning', 'claude-opus-4-5-20250301', 'deepseek-r1'),
    (NEW.id, 'quick', 'claude-haiku-4-5-20251001', 'gpt-4o-mini');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_profile_created_seed_roles
  AFTER INSERT ON public.profiles
  FOR EACH ROW EXECUTE FUNCTION public.seed_model_roles();
```

### New Table: instance\_types 

Instance types are reusable templates that provide default settings for Instances of that type. When a user creates an Instance and selects a type, the type's `default_settings` are merged into the cascade (Part 7). The `instances.type` column references the type name, not a foreign key — this allows built-in types to exist without user-specific rows.

```sql
CREATE TABLE public.instance_types (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  description TEXT DEFAULT '',
  default_settings JSONB NOT NULL DEFAULT '{}'::jsonb,
  default_persona_ids UUID[] DEFAULT '{}',
  is_builtin BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(user_id, name)
);

ALTER TABLE public.instance_types ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own instance types"
  ON public.instance_types FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_instance_types_user ON public.instance_types(user_id);

CREATE TRIGGER set_instance_types_updated_at
  BEFORE UPDATE ON public.instance_types
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

TypeScript interface:

```ts
interface InstanceType {
  id: string;
  user_id: string;
  name: string;
  description: string;
  default_settings: InstanceSettings;
  default_persona_ids: string[];
  is_builtin: boolean;
  created_at: string;
  updated_at: string;
  // Computed client-side
  instance_count?: number;
}

interface InstanceSettings {
  voice?: string;
  tone?: string;
  personality?: string;
  cleanup_frequency?: "hourly" | "daily" | "weekly" | "monthly" | "never";
  file_visibility?: "visible" | "instance" | "hidden";
  storage_location?: string;
  max_tokens?: number;
  greeting_style?: string;
}
```

**Built-in types** seeded on account creation:

| Name | Description | Default Settings |
| :---- | :---- | :---- |
| Business | Professional client work | voice: "Professional", cleanup: "weekly", greeting: "Formal" |
| Personal | Personal projects and notes | voice: "Casual, warm", cleanup: "monthly", greeting: "Friendly" |
| Project | Technical project workspace | voice: "Technical, precise", cleanup: "weekly", greeting: "Brief" |
| Research | Deep research and analysis | voice: "Academic", cleanup: "never", greeting: "Brief" |

```sql
CREATE OR REPLACE FUNCTION public.seed_instance_types()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.instance_types (user_id, name, description, default_settings, is_builtin)
  VALUES
    (NEW.id, 'Business', 'Professional client work',
     '{"voice":"Professional","cleanup_frequency":"weekly","greeting_style":"Formal"}'::jsonb, true),
    (NEW.id, 'Personal', 'Personal projects and notes',
     '{"voice":"Casual, warm","cleanup_frequency":"monthly","greeting_style":"Friendly"}'::jsonb, true),
    (NEW.id, 'Project', 'Technical project workspace',
     '{"voice":"Technical, precise","cleanup_frequency":"weekly","greeting_style":"Brief"}'::jsonb, true),
    (NEW.id, 'Research', 'Deep research and analysis',
     '{"voice":"Academic","cleanup_frequency":"never","greeting_style":"Brief"}'::jsonb, true);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_profile_created_seed_types
  AFTER INSERT ON public.profiles
  FOR EACH ROW EXECUTE FUNCTION public.seed_instance_types();
```

### New Table: instruction\_memory 

Instruction memory stores behavioral rules that Cipher has learned from user interactions. Rules are scoped to Global, a specific Instance Type, or a specific Instance. Cipher includes active rules in the system prompt when assembling context for a response (Part 14).

```sql
CREATE TABLE public.instruction_memory (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  rule TEXT NOT NULL,
  description TEXT DEFAULT '',
  scope TEXT NOT NULL DEFAULT 'global'
    CHECK (scope IN ('global', 'type', 'instance')),
  scope_target_id UUID,                  -- NULL for global, instance_type.id for type, instance.id for instance
  scope_target_name TEXT DEFAULT '',      -- Human-readable: "Business", "Client Website Redesign"
  source_chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  source_description TEXT DEFAULT '',     -- "Hero Section Layout", "Budget Review"
  active BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.instruction_memory ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own instruction memory"
  ON public.instruction_memory FOR ALL
  USING (auth.uid() = user_id);

CREATE INDEX idx_instruction_memory_user ON public.instruction_memory(user_id);
CREATE INDEX idx_instruction_memory_active ON public.instruction_memory(user_id, active);
CREATE INDEX idx_instruction_memory_scope ON public.instruction_memory(user_id, scope, scope_target_id);

CREATE TRIGGER set_instruction_memory_updated_at
  BEFORE UPDATE ON public.instruction_memory
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

TypeScript interface:

```ts
interface InstructionMemoryRule {
  id: string;
  user_id: string;
  rule: string;
  description: string;
  scope: "global" | "type" | "instance";
  scope_target_id: string | null;
  scope_target_name: string;
  source_chat_id: string | null;
  source_description: string;
  active: boolean;
  created_at: string;
  updated_at: string;
}
```

### Existing Tables Referenced 

| Table | Defined In | Settings Purpose |
| :---- | :---- | :---- |
| `profiles` | Part 2 | `preferences` JSONB stores General tab settings (theme, interface\_mode, voice\_tone, auto\_rename, suggest\_moves, scheduled\_cleanup, cleanup\_frequency, show\_routing\_notes, show\_memory\_extraction, notifications) |
| `api_keys` | Part 2 | API key storage (provider, encrypted\_key, key\_hint, status) |
| `subscriptions` | Part 2 | Stripe subscription, tier, billing period |
| `credit_transactions` | Part 2 | Credit balance and history |
| `instances` | Part 7 | `settings` JSONB stores Instance-level overrides |

### Global Instance Settings 

The `profiles.preferences` JSONB includes a `global_instance_settings` key that stores defaults applied to all Instances before type templates and instance overrides:

```ts
// Addition to profiles.preferences JSONB shape
interface ProfilePreferences {
  // ... existing fields from Part 2 ...
  global_instance_settings: InstanceSettings;
}
```

Default value:

```json
{
  "global_instance_settings": {
    "cleanup_frequency": "weekly",
    "file_visibility": "visible",
    "storage_location": "local",
    "max_tokens": 4096,
    "greeting_style": "Warm"
  }
}
```

---

## 13.2 Screen Layout 

### Container 

```ts
{
  display: "flex",
  flexDirection: "column",
  height: "100%",
  maxWidth: 800,
  margin: "0 auto",
  width: "100%",
}
```

### Header 

```ts
{
  padding: `${py}px ${px}px 0`,
  flexShrink: 0,
}
```

- Title: "Settings" (28px, weight 300, `text`, letterSpacing \-0.03em, marginBottom 28px)

### Tab Bar 

Horizontal underline-style tabs (not pill-style). Differs from analytics pill tabs — settings uses an underlined tab bar with bottom border to match the page-level navigation pattern.

```ts
{
  display: "flex",
  gap: mobile ? 14 : 20,
  borderBottom: `1px solid ${theme.borderSubtle}`,
  overflowX: "auto",
}
```

6 tabs:

| ID | Label | Tier Gate |
| :---- | :---- | :---- |
| `general` | General | All tiers |
| `models` | Models | All tiers |
| `keys` | API Keys | All tiers |
| `types` | Types | Premium+ |
| `hierarchy` | Cascade | Premium+ |
| `learned` | Learned Rules | Plus+ |

Each tab button:

```ts
{
  padding: "0 0 12px",
  fontSize: 13,
  fontWeight: isActive ? 500 : 350,
  color: isActive ? theme.text : theme.textMuted,
  background: "none",
  border: "none",
  borderBottom: isActive
    ? `1.5px solid ${theme.accent}`
    : "1.5px solid transparent",
  cursor: "pointer",
  whiteSpace: "nowrap",
  flexShrink: 0,
  display: "flex",
  alignItems: "center",
  gap: 4,
}
```

Tier-gated tabs show a Lock icon (8px, `textFaint`) after the label for users below the required tier. Clicking a locked tab shows the gated content (same pattern as Part 12, Section 12.10): Lock icon \+ "Unlock {feature}" \+ description \+ "Upgrade" button.

### Tab Content Area 

```ts
{
  flex: 1,
  overflowY: "auto",
  padding: `24px ${px}px ${py}px`,
}
```

---

## 13.3 General Tab 

The General tab contains all primary configuration: interface mode, account info, billing, voice/tone, storage, integrations, system intelligence, onboarding replay, and chat cleanup settings.

### Setting Row Component 

Most General tab items use a consistent row layout:

```ts
// SettingRow
{
  padding: "18px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  alignItems: "center",
  gap: 16,
  cursor: "pointer",
}
```

**Left icon:** Lucide icon (18px, `textFaint`)

**Center (flex: 1):**

- Label: 15px, weight 450, `text`  
- Description: 13px, weight 300, `textMuted`, marginTop 2px

**Right:** Control element (varies per row: toggle, button, chevron, segmented selector)

### Row 1: Interface Mode 

Controls whether the user sees the simplified Standard interface or the full Power User interface with all features exposed. This setting is stored in `profiles.preferences.interface_mode`.

**Icon:** Sliders (18px)

**Label:** "Interface Mode"

**Description:** Dynamic based on current mode:

- Standard: "Standard — simplified interface"  
- Power: "Power User — all features visible"

**Control:** Segmented button:

```ts
{
  display: "flex",
  background: theme.surfaceAlt,
  borderRadius: 8,
  overflow: "hidden",
}
```

Two segments: "Standard" and "Power". Each:

```ts
{
  padding: "5px 12px",
  fontSize: 11,
  border: "none",
  cursor: "pointer",
  background: isActive ? theme.accent : "transparent",
  color: isActive ? theme.accentText : theme.textMuted,
  fontWeight: 400,
  transition: "background 0.15s ease, color 0.15s ease",
}
```

**On change:**

```ts
async function updateInterfaceMode(mode: "standard" | "power") {
  const supabase = createClient();
  const { error } = await supabase
    .from("profiles")
    .update({
      preferences: {
        ...currentPreferences,
        interface_mode: mode,
      },
    })
    .eq("id", userId);

  if (!error) {
    queryClient.invalidateQueries({ queryKey: ["profile"] });
  }
}
```

When switching to Standard mode, Power-only UI elements across the app are hidden: advanced search operators, Cascade tab, browser DOM tools, team execution controls, and raw routing notes. The user can always switch back.

### Row 2: Account 

**Icon:** User (18px)

**Label:** "Account"

**Description:** "{display\_name} · {email}"

**Control:** ChevronRight (16px, `textFaint`)

**Click action:** Opens Account Edit Modal.

**Account Edit Modal:**

```ts
// Backdrop
{
  position: "fixed",
  inset: 0,
  background: "rgba(0,0,0,0.4)",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  zIndex: 50,
}

// Modal
{
  background: theme.surface,
  borderRadius: 16,
  padding: 24,
  width: mobile ? "90%" : 420,
  boxShadow: "0 20px 60px rgba(0,0,0,0.2)",
}
```

**Header:** "Account" (16px, weight 450, `text`) \+ X close button (16px, `textMuted`)

**Fields:**

Display Name input:

```ts
{
  width: "100%",
  padding: "10px 14px",
  background: theme.inputBg,
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  fontSize: 13,
  color: theme.text,
  fontWeight: 400,
  outline: "none",
  marginTop: 16,
}
// Focus: border accent
```

- Label: "Display Name" (11px, weight 400, `textMuted`, marginTop 20px)  
- Current value pre-filled from `profiles.display_name`

Email display:

- Label: "Email" (11px, weight 400, `textMuted`, marginTop 16px)  
- Value: email from auth.users (13px, weight 350, `textSec`). Non-editable — displayed as text, not input.  
- Below email: "Managed by your login provider" (10px, `textFaint`) if OAuth, or "Change Email" link (10px, accent color, cursor pointer) if email/password auth.

Change Password section (email/password users only):

- "Change Password" button (marginTop 16px, bordered, 12px, `textMuted`). Triggers Supabase `auth.resetPasswordForEmail()` which sends a reset email.  
- Confirmation text: "A password reset link will be sent to your email." (10px, `textFaint`, marginTop 4px)

**Save button:** Full width, accent bg, accentText, borderRadius 8, padding 10px, fontSize 13, fontWeight 450, marginTop 24px. Disabled if no changes detected.

**Delete Account:** Below save button, marginTop 20px, textAlign center:

- "Delete Account" link (12px, `#ef4444`, cursor pointer)  
- Click opens confirmation: "This will permanently delete your account and all data. Type 'DELETE' to confirm."  
- Text input for "DELETE" confirmation \+ "Delete My Account" button (red bg, white text)  
- Implementation: calls Edge Function `delete-account` which cascades through all user data, cancels Stripe subscription, then calls `supabase.auth.admin.deleteUser()`

### Row 3: Billing 

**Icon:** CreditCard (18px)

**Label:** "Billing"

**Description:** "{tier\_name} · ${price}/mo" (e.g., "Pro · $99.99/mo")

**Control:** "Compare Plans" button:

```ts
{
  background: "none",
  border: `1px solid ${theme.border}`,
  borderRadius: 6,
  padding: "4px 12px",
  fontSize: 11,
  color: theme.textMuted,
  cursor: "pointer",
}
```

Click opens PricingModal (defined in Part 2).

**Billing detail badges** (marginTop 12px, paddingLeft 34px, flex, gap 12px, flexWrap wrap):

```ts
// Badge container
{
  padding: "6px 12px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  fontSize: 12,
}
```

**BYOK badge:**

- Label: "BYOK:" (weight 350, `textMuted`)  
- Value: "Active" (weight 400, `dot.active` green) if user has active API keys, else "Not connected" (weight 400, `textFaint`)

**Credits badge:**

- Label: "Credits:" (weight 350, `textMuted`)  
- Value: "{n} remaining" (weight 400, `text`). Value from `profiles.credits`.

**Feature gate indicators** (marginTop 10px, paddingLeft 34px, flex, gap 6px, flexWrap wrap):

Each unlocked feature for the user's tier shows as a small badge:

```ts
{
  display: "inline-flex",
  alignItems: "center",
  gap: 4,
  padding: "3px 8px",
  background: theme.surfaceAlt,
  borderRadius: 6,
  fontSize: 10,
  color: theme.textFaint,
}
```

- Shield icon (8px) \+ Feature name \+ green checkmark (weight 450, `dot.active`)

Features shown vary by tier:

| Feature | Free | Plus | Premium | Pro |
| :---- | :---- | :---- | :---- | :---- |
| Agentic Teams | — | — | ✓ | ✓ |
| Browser | — | — | — | ✓ |
| Encrypted Export | — | — | — | ✓ |
| API Access | — | — | — | ✓ |
| Custom Types | — | — | ✓ | ✓ |
| Advanced Analytics | — | — | ✓ | ✓ |

Locked features (for the current tier) are hidden entirely — only unlocked features are displayed.

**Subscription management links** (marginTop 10px, paddingLeft 34px):

- "Manage Subscription" link (11px, accent color, cursor pointer). Opens Stripe Customer Portal via `supabase.functions.invoke("create-portal-session")`. The portal handles upgrades, downgrades, cancellation, and payment method changes.  
- "View Invoice History" link (11px, `textMuted`, cursor pointer, marginLeft 16px). Opens Stripe Customer Portal invoice tab.

### Row 4: Voice & Tone 

**Icon:** Volume2 (18px)

**Label:** "Voice & Tone"

**Description:** Current voice setting (e.g., "Professional, warm")

**Control:** ChevronRight (16px, `textFaint`)

**Click action:** Opens Voice & Tone Editor Modal.

**Voice & Tone Modal:**

Same modal shell as Account modal (420px width).

**Header:** "Voice & Tone" (16px, weight 450\)

**Description:** "Set the default communication style for your Personas. Individual Instances can override this." (12px, weight 300, `textMuted`, marginTop 8px, lineHeight 1.5)

**Preset Selector** (marginTop 20px):

```ts
{
  display: "flex",
  flexWrap: "wrap",
  gap: 6,
}
```

Preset options: Professional, Casual, Academic, Creative, Technical, Custom. Each:

```ts
{
  padding: "6px 14px",
  borderRadius: 20,
  fontSize: 11,
  fontWeight: 400,
  border: "none",
  cursor: "pointer",
  background: isSelected ? theme.accent : theme.surfaceAlt,
  color: isSelected ? theme.accentText : theme.textMuted,
}
```

Selecting a preset fills the text area below with a predefined voice description:

| Preset | Description |
| :---- | :---- |
| Professional | "Professional and direct. Clear, confident language with minimal filler." |
| Casual | "Casual and warm. Conversational tone, friendly language, approachable." |
| Academic | "Academic and precise. Formal structure, citations, measured claims." |
| Creative | "Creative and expressive. Playful language, metaphors, exploratory tone." |
| Technical | "Technical and precise. Exact terminology, structured explanations, code-first." |
| Custom | (User writes their own) |

**Custom Text Area** (marginTop 16px):

```ts
{
  width: "100%",
  minHeight: 80,
  padding: "10px 14px",
  background: theme.inputBg,
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  fontSize: 13,
  color: theme.text,
  fontWeight: 350,
  lineHeight: 1.5,
  resize: "vertical",
  outline: "none",
  fontFamily: "inherit",
}
```

Pre-filled with the current `profiles.preferences.voice_tone` value.

**Save button:** Same as Account modal. Updates `profiles.preferences.voice_tone`.

### Row 5: Storage 

**Icon:** HardDrive (18px)

**Label:** "Storage"

**Description:** "{used} of {total}" (e.g., "2.4 GB of 200 GB")

**Control:** ChevronRight (16px, `textFaint`)

**Click action:** Opens Storage Detail Modal.

**Storage Detail Modal:**

Same modal shell (420px width).

**Header:** "Storage" (16px, weight 450\)

**Progress bar** (marginTop 16px):

```ts
// Track
{
  height: 6,
  borderRadius: 3,
  background: theme.surfaceAlt,
  overflow: "hidden",
}

// Fill
{
  height: "100%",
  borderRadius: 3,
  background: percentage > 90 ? "#ef4444" : percentage > 75 ? "#f59e0b" : theme.accent,
  width: `${percentage}%`,
  transition: "width 0.3s ease",
}
```

- Below bar: "{used} of {total} ({percentage}%)" (12px, weight 350, `textMuted`, marginTop 8px)

**Breakdown** (marginTop 20px):

```ts
{
  display: "flex",
  flexDirection: "column",
  gap: 10,
}
```

3 rows showing storage by category:

| Category | Icon | Value Source |
| :---- | :---- | :---- |
| User Uploads | Upload (14px) | SUM(size\_bytes) WHERE origin \= 'uploaded' |
| AI Generated Files | Sparkles (14px) | SUM(size\_bytes) WHERE origin \= 'ai\_generated' |
| Browser Extracts | Globe (14px) | SUM(size\_bytes) WHERE origin \= 'browser\_extract' |

Each row:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 12,
  padding: "8px 0",
}
```

- Icon (14px, `textFaint`)  
- Label (13px, weight 400, `text`, flex: 1\)  
- Size (13px, weight 350, `textMuted`)

**Data query:**

```sql
SELECT
  origin,
  COUNT(*) AS file_count,
  COALESCE(SUM(size_bytes), 0) AS total_bytes
FROM files
WHERE user_id = $1 AND deleted_at IS NULL
GROUP BY origin;
```

**Tier limit text** (marginTop 16px):

- "Your {tier\_name} plan includes {total} storage." (11px, weight 300, `textFaint`)  
- If usage \> 75%: "Running low on space. " \+ "Upgrade" link (accent color) opening PricingModal.

### Row 6: Integrations 

**Icon:** Link2 (18px)

**Label:** "Integrations"

**Description:** "{n} connected" (count of active integrations)

**Control:** ChevronRight (16px, `textFaint`)

**Tier badge:** If the feature requires a higher tier, show a small tier badge after the label:

```ts
{
  fontSize: 8,
  padding: "1px 5px",
  borderRadius: 4,
  background: theme.accent,
  color: theme.accentText,
  fontWeight: 600,
}
// Text: "Pro" or "Premium+"
```

**Click action:** Opens Integrations Panel.

**Integrations Panel** (same modal shell, 480px width):

**Header:** "Integrations" (16px, weight 450\) \+ X close button

**Description:** "Connect external services to expand your workspace." (12px, weight 300, `textMuted`, marginTop 6px)

**Available integrations list:**

| Integration | Status | Tier |
| :---- | :---- | :---- |
| Google Drive | Available v1 | Premium+ |
| Dropbox | Planned v2 | Premium+ |
| Notion | Planned v2 | Pro |
| GitHub | Planned v2 | Pro |
| Slack | Planned v2 | Pro |
| Zapier | Planned v2 | Pro |

Each integration row:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 14,
  padding: "14px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
}
```

- Icon area: 36px × 36px rounded rect (surfaceAlt bg, borderRadius 8), integration logo/icon centered  
- Name (14px, weight 450, `text`) \+ description (11px, weight 300, `textMuted`, marginTop 2px)  
- Status: "Connected" (10px, `dot.active`) with green dot, or "Connect" button (bordered, 11px), or "Coming Soon" (10px, `textFaint`)

**Google Drive connection flow** (v1):

1. User clicks "Connect" on Google Drive  
2. Opens OAuth consent screen for Google Drive API (read/write scope)  
3. On success, stores OAuth tokens in `integrations` table (future, not detailed here)  
4. Shows "Connected" status with account email

### System Intelligence Section 

Below the standard rows, a grouped settings card for System Intelligence controls:

```ts
{
  marginTop: 20,
  padding: "14px 16px",
  background: theme.surfaceAlt,
  borderRadius: 10,
}
```

**Section title:** "System Intelligence" (13px, weight 450, `text`, marginBottom 10px)

2 toggle rows:

| Toggle | Description | Preference Key |
| :---- | :---- | :---- |
| Show routing notes | "Display Cipher routing decisions in chat" | `show_routing_notes` |
| Show memory extraction | "Indicate when memories are saved from messages" | `show_memory_extraction` |

Each toggle row:

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  padding: "6px 0",
}
```

**Left:**

- Label: 12px, weight 400, `text`  
- Description: 10px, weight 300, `textMuted`

**Right:** Toggle button:

```ts
// Active state
{
  background: "none",
  border: "none",
  cursor: "pointer",
  color: theme.dot.active,  // Green when on
  display: "flex",
}
// ToggleRight icon (18px)

// Inactive state
{
  color: theme.textFaint,
}
// ToggleLeft icon (18px)
```

**On toggle:**

```ts
async function togglePreference(key: string) {
  const newValue = !currentPreferences[key];
  const supabase = createClient();
  await supabase
    .from("profiles")
    .update({
      preferences: { ...currentPreferences, [key]: newValue },
    })
    .eq("id", userId);
  queryClient.invalidateQueries({ queryKey: ["profile"] });
}
```

### Onboarding Replay 

Below System Intelligence:

```ts
{
  marginTop: 16,
  padding: "12px 16px",
  background: theme.surfaceAlt,
  borderRadius: 10,
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  cursor: "pointer",
}
```

- "Replay Welcome Tour" (13px, weight 400, `text`)  
- "See the product walkthrough again" (11px, weight 300, `textMuted`)  
- ChevronRight (14px, `textFaint`)

**Click:** Resets onboarding state and opens the onboarding modal (Part 2\) at step 0\.

### Chat Cleanup & Organization Section 

Grouped settings card:

```ts
{
  marginTop: 20,
  padding: "14px 16px",
  background: theme.surfaceAlt,
  borderRadius: 10,
}
```

**Section title:** "Chat Cleanup & Organization" (13px, weight 450, `text`, marginBottom 10px)

3 toggle rows \+ 1 frequency selector:

| Toggle | Description | Key | Default |
| :---- | :---- | :---- | :---- |
| Auto-rename conversations | "Suggest better names after context is established" | `auto_rename` | true |
| Suggest moves to Instances | "Prompt to move chats matching an existing Instance" | `suggest_moves` | true |
| Scheduled cleanup | "Background scan for unorganized conversations" | `scheduled_cleanup` | false |

Same toggle row layout as System Intelligence.

**Cleanup Frequency Selector** (marginTop 8px, only visible when scheduled\_cleanup is true):

```ts
{
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  padding: "6px 0",
}
```

- Label: "Cleanup frequency" (12px, weight 400, `text`)  
- Description: "How often the background scan runs" (10px, weight 300, `textMuted`)

**Frequency options** (flex, gap 4px):

3 buttons: "Hourly", "Daily", "Weekly". Each:

```ts
{
  padding: "3px 8px",
  borderRadius: 5,
  fontSize: 10,
  border: isActive ? "none" : `1px solid ${theme.border}`,
  background: isActive ? theme.accent : "none",
  color: isActive ? theme.accentText : theme.textMuted,
  cursor: "pointer",
}
```

Default: "Daily". Stored in `profiles.preferences.cleanup_frequency`.

### Danger Zone 

At the bottom of General tab, a subtle section:

```ts
{
  marginTop: 40,
  padding: "14px 16px",
  borderRadius: 10,
  border: `1px solid rgba(239,68,68,0.2)`,
}
```

**Section title:** "Danger Zone" (13px, weight 450, `#ef4444`, marginBottom 10px)

**Sign Out:** Row with LogOut icon (14px, `#ef4444`), "Sign Out" (13px, weight 400, `#ef4444`), "End your current session" (11px, weight 300, `textMuted`). Click: `supabase.auth.signOut()` → redirect to login.

**Delete Account:** Row with Trash2 icon (14px, `#ef4444`), "Delete Account" (13px, weight 400, `#ef4444`), "Permanently remove your account and all data" (11px, weight 300, `textMuted`). Click: opens the confirmation modal described in Row 2\.

---

## 13.4 Models Tab 

The Models tab displays role-to-model assignments. Cipher uses these assignments to route messages to the appropriate AI model based on detected intent (see Part 14). Each role has a primary model and an optional fallback that activates if the primary is unavailable or rate-limited.

### Tab Header 

```ts
{
  marginBottom: 24,
}
```

- "Role Assignments" (14px, weight 450, `text`, marginBottom 4px)  
- "Each role routes to a primary model with an automatic fallback." (12px, weight 300, `textMuted`)

### Available Models Registry 

Models available to the user depend on tier and BYOK status:

```ts
interface ModelOption {
  id: string;         // Model API string
  name: string;       // Display name
  provider: string;   // Provider name
  tier_required: "free" | "plus" | "premium" | "pro";
  byok_only: boolean; // Only available with user's own API key
  capabilities: string[];  // Which roles this model can serve
}

const AVAILABLE_MODELS: ModelOption[] = [
  {
    id: "claude-haiku-4-5-20251001",
    name: "Claude Haiku 4.5",
    provider: "Anthropic",
    tier_required: "free",
    byok_only: false,
    capabilities: ["writing", "quick", "coding"],
  },
  {
    id: "claude-sonnet-4-5-20250929",
    name: "Claude Sonnet 4.5",
    provider: "Anthropic",
    tier_required: "plus",
    byok_only: false,
    capabilities: ["research", "writing", "coding", "planning", "reasoning", "quick"],
  },
  {
    id: "claude-opus-4-5-20250301",
    name: "Claude Opus 4.5",
    provider: "Anthropic",
    tier_required: "premium",
    byok_only: false,
    capabilities: ["research", "planning", "reasoning"],
  },
  {
    id: "gpt-4o",
    name: "GPT-4o",
    provider: "OpenAI",
    tier_required: "plus",
    byok_only: false,
    capabilities: ["research", "writing", "coding", "planning", "reasoning", "quick"],
  },
  {
    id: "gpt-4o-mini",
    name: "GPT-4o Mini",
    provider: "OpenAI",
    tier_required: "free",
    byok_only: false,
    capabilities: ["writing", "quick", "coding"],
  },
  {
    id: "gemini-2.5-pro",
    name: "Gemini 2.5 Pro",
    provider: "Google",
    tier_required: "plus",
    byok_only: true,
    capabilities: ["research", "writing", "coding", "reasoning"],
  },
  {
    id: "gemini-2.5-flash",
    name: "Gemini 2.5 Flash",
    provider: "Google",
    tier_required: "free",
    byok_only: true,
    capabilities: ["writing", "quick", "coding"],
  },
  {
    id: "deepseek-r1",
    name: "DeepSeek R1",
    provider: "DeepSeek",
    tier_required: "plus",
    byok_only: true,
    capabilities: ["reasoning", "coding", "research"],
  },
  {
    id: "dall-e-3",
    name: "DALL-E 3",
    provider: "OpenAI",
    tier_required: "plus",
    byok_only: true,
    capabilities: ["creative"],
  },
];
```

### Role Assignment Rows 

```ts
{
  display: "flex",
  flexDirection: "column",
  gap: 0,
}
```

Each role row:

```ts
{
  padding: "16px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  alignItems: mobile ? "flex-start" : "center",
  gap: 14,
  flexDirection: mobile ? "column" : "row",
}
```

**Left: Role label** (minWidth 110px, flex, alignItems center, gap 8px):

```ts
<span style={{ color: theme.textFaint }}>{roleIcon}</span>
<span style={{ fontSize: 14, color: theme.text, fontWeight: 450 }}>{roleName}</span>
```

Role icons:

| Role | Icon |
| :---- | :---- |
| Research | Search (14px) |
| Writing | FileText (14px) |
| Coding | Code (14px) |
| Creative | Palette (14px) |
| Planning | Layers (14px) |
| Reasoning | Brain (14px) |
| Quick | Zap (14px) |

**Center: Model chips** (flex: 1, flex, gap 10px, alignItems center, flexWrap wrap):

Primary model chip:

```ts
{
  padding: "6px 14px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  fontSize: 12,
  color: theme.text,
  fontWeight: 400,
  display: "flex",
  alignItems: "center",
  gap: 6,
  cursor: "pointer",
}
// Hover: border accent
```

Content: "{model\_display\_name}" \+ "primary" badge (9px, `textFaint`, weight 300\)

Arrow icon between primary and fallback: ArrowRight (12px, `textFaint`)

Fallback model chip:

```ts
{
  padding: "6px 14px",
  background: "transparent",
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  fontSize: 12,
  color: theme.textMuted,
  fontWeight: 350,
  display: "flex",
  alignItems: "center",
  gap: 6,
  cursor: "pointer",
}
```

Content: "{model\_display\_name}" \+ "fallback" badge (9px, `textFaint`, weight 300\)

If no fallback is set: dashed border chip with "Set fallback" text (`textFaint`).

**Right: Provider** (10px, weight 300, `textFaint`, flexShrink 0\)

### Model Selection Popover 

Clicking either the primary or fallback chip opens a model selection popover anchored to that chip:

```ts
{
  position: "absolute",
  top: "100%",
  left: 0,
  marginTop: 4,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  padding: 8,
  width: 280,
  maxHeight: 300,
  overflowY: "auto",
  boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
  zIndex: 40,
}
```

**Search input** at top:

```ts
{
  padding: "6px 10px",
  background: theme.surfaceAlt,
  border: `1px solid ${theme.borderSubtle}`,
  borderRadius: 6,
  fontSize: 11,
  color: theme.text,
  width: "100%",
  marginBottom: 6,
  outline: "none",
}
```

Placeholder: "Search models..."

**Model list:** Filtered to models whose `capabilities` include the current role, and whose `tier_required` is at or below the user's tier (or the user has BYOK for `byok_only` models). Each model option:

```ts
{
  padding: "8px 10px",
  borderRadius: 6,
  display: "flex",
  alignItems: "center",
  gap: 10,
  cursor: "pointer",
  fontSize: 12,
}
// Hover: background surfaceAlt
// Selected: background surfaceAlt, accent left border (2px)
```

- Model name (12px, weight 400, `text`)  
- Provider badge (9px, `textFaint`, weight 300\)  
- If the model is the current selection: Check icon (12px, accent)  
- If the model requires a higher tier: Lock icon (10px, `textFaint`) \+ tier label

**On select:**

```ts
async function updateRoleModel(
  role: string,
  field: "primary_model" | "fallback_model",
  modelId: string
) {
  const supabase = createClient();
  await supabase
    .from("model_role_assignments")
    .update({ [field]: modelId })
    .eq("user_id", userId)
    .eq("role", role);

  queryClient.invalidateQueries({ queryKey: ["model-roles"] });
}
```

### Add Custom Role Button 

Below the role list:

```ts
{
  marginTop: 20,
  display: "flex",
  alignItems: "center",
  gap: 5,
  background: "none",
  border: `1px dashed ${theme.border}`,
  borderRadius: 8,
  padding: "8px 16px",
  fontSize: 12,
  color: theme.textMuted,
  cursor: "pointer",
}
```

Plus icon (12px) \+ "Add Custom Role"

**Click:** Inline form appears above the button:

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 10,
  padding: "12px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
}
```

- Text input for role name (placeholder: "Role name", 13px, flex: 1, inputBg, border, borderRadius 6, padding 6px 10px)  
- "Add" button (accent bg, accentText, borderRadius 6, padding 4px 12px, fontSize 11\)  
- "Cancel" button (border, textMuted, borderRadius 6, padding 4px 12px, fontSize 11\)

On add: inserts a new row into `model_role_assignments` with the default model for that tier as primary, no fallback.

### Data Fetching 

```ts
const { data: modelRoles } = useQuery({
  queryKey: ["model-roles"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("model_role_assignments")
      .select("*")
      .order("created_at", { ascending: true });
    return data ?? [];
  },
});
```

---

## 13.5 API Keys Tab 

Manages BYOK (Bring Your Own Key) API key connections. Keys are encrypted at rest and never displayed in full after initial submission.

### Tab Header 

- "BYOK — Bring Your Own Keys" (14px, weight 450, `text`, marginBottom 4px)  
- "Connect your own API keys for direct model access. Keys are encrypted at rest." (12px, weight 300, `textMuted`)

### Key List 

```ts
{
  display: "flex",
  flexDirection: "column",
  gap: 0,
}
```

Each key row:

```ts
{
  padding: "16px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  alignItems: "center",
  gap: 14,
}
```

**Left: Provider icon area**

```ts
{
  width: 36,
  height: 36,
  borderRadius: 8,
  background: theme.surfaceAlt,
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  flexShrink: 0,
}
```

Provider icon: Cpu (16px, `textMuted`) for generic, or provider-specific icon if available.

**Center: Key info (flex: 1\)**

- Provider name \+ status dot: "{label}" (14px, weight 450, `text`) \+ inline StatusDot (6px, green for active, gray for inactive, red for invalid)

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 8,
}
```

- Metadata row (marginTop 2px, 11px, weight 300, `textMuted`, flex, gap 10px):

```ts
<span style={{ fontFamily: "monospace", fontSize: 10 }}>{key_hint}</span>
<span>Added: {formatDate(created_at)}</span>
<span>Last validated: {relativeTime(last_validated_at)}</span>
```

Key hint format: "sk-ant-...{last4}" (only last 4 chars visible).

**Right: Action buttons** (flex, gap 6px):

"Test" button:

```ts
{
  background: "none",
  border: `1px solid ${theme.border}`,
  borderRadius: 6,
  padding: "4px 10px",
  fontSize: 11,
  color: theme.textMuted,
  cursor: "pointer",
}
```

Click: calls `supabase.functions.invoke("validate-api-key", { body: { key_id } })`. Shows spinner while validating. On success: updates status to "active", shows green check toast. On failure: updates status to "invalid", shows error toast.

"Remove" button (same styling but `color: #ef4444`):

Click: opens inline confirmation.

**Inline remove confirmation:**

```ts
{
  marginTop: 8,
  padding: "8px 12px",
  background: "rgba(239,68,68,0.05)",
  borderRadius: 8,
  display: "flex",
  alignItems: "center",
  gap: 10,
}
```

- "Remove this key? This cannot be undone." (11px, weight 350, `textMuted`)  
- "Cancel" button (bordered, 10px)  
- "Remove" button (red bg, white text, 10px)

On confirm:

```ts
async function removeApiKey(keyId: string) {
  const supabase = createClient();
  await supabase.from("api_keys").delete().eq("id", keyId);
  queryClient.invalidateQueries({ queryKey: ["api-keys"] });
  showToast("API key removed.");
}
```

### Add API Key Flow 

"Add API Key" button below the list:

```ts
{
  marginTop: 20,
  display: "flex",
  alignItems: "center",
  gap: 5,
  background: "none",
  border: `1px dashed ${theme.border}`,
  borderRadius: 8,
  padding: "8px 16px",
  fontSize: 12,
  color: theme.textMuted,
  cursor: "pointer",
}
```

Plus icon (12px) \+ "Add API Key"

**Click:** Opens Add Key Modal.

**Add Key Modal:**

```ts
// Modal: same shell as Account modal (420px)
```

**Header:** "Add API Key" (16px, weight 450\)

**Step 1: Provider Selection** (marginTop 16px)

Label: "Provider" (11px, weight 400, `textMuted`, marginBottom 8px)

Provider options as a grid:

```ts
{
  display: "grid",
  gridTemplateColumns: "1fr 1fr",
  gap: 8,
}
```

| Provider | Label | Key Prefix |
| :---- | :---- | :---- |
| OpenRouter | "OpenRouter (Recommended)" | sk-or- |
| Anthropic | "Anthropic" | sk-ant- |
| OpenAI | "OpenAI" | sk- |
| Google | "Google AI" | AI... |

Each provider option:

```ts
{
  padding: "10px 14px",
  border: isSelected
    ? `1.5px solid ${theme.accent}`
    : `1px solid ${theme.border}`,
  borderRadius: 8,
  cursor: "pointer",
  textAlign: "center",
}
```

- Provider name (12px, weight 400, `text`)  
- "Recommended" label (9px, `textFaint`) only for OpenRouter

**Step 2: Key Input** (marginTop 16px)

Label: "API Key" (11px, weight 400, `textMuted`, marginBottom 6px)

```ts
{
  width: "100%",
  padding: "10px 14px",
  background: theme.inputBg,
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  fontSize: 13,
  color: theme.text,
  fontFamily: "monospace",
  outline: "none",
}
// type: "password" (dots by default)
// Toggle eye icon to show/hide
```

Placeholder: "Paste your API key here"

Below input: "Your key is encrypted at rest and never stored in plaintext." (10px, weight 300, `textFaint`, marginTop 4px)

**Step 3: Test Connection** (marginTop 16px)

"Test Connection" button:

```ts
{
  width: "100%",
  padding: "10px",
  background: theme.accent,
  color: theme.accentText,
  border: "none",
  borderRadius: 8,
  fontSize: 13,
  fontWeight: 450,
  cursor: "pointer",
}
```

**States:**

- Idle: "Test Connection"  
- Testing: Spinner \+ "Validating..."  
- Success: Check icon \+ "Connection successful" (green text)  
- Failed: AlertCircle icon \+ "Invalid key" (red text) \+ error message

**Validation Edge Function:**

```ts
// supabase/functions/validate-api-key/index.ts
async function validateKey(provider: string, key: string): Promise<boolean> {
  switch (provider) {
    case "openrouter":
      // GET https://openrouter.ai/api/v1/auth/key
      const res = await fetch("https://openrouter.ai/api/v1/auth/key", {
        headers: { Authorization: `Bearer ${key}` },
      });
      return res.ok;

    case "anthropic":
      // POST https://api.anthropic.com/v1/messages with minimal payload
      const res2 = await fetch("https://api.anthropic.com/v1/messages", {
        method: "POST",
        headers: {
          "x-api-key": key,
          "anthropic-version": "2023-06-01",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          model: "claude-haiku-4-5-20251001",
          max_tokens: 1,
          messages: [{ role: "user", content: "test" }],
        }),
      });
      return res2.ok || res2.status === 429; // 429 = valid key, rate limited

    case "openai":
      const res3 = await fetch("https://api.openai.com/v1/models", {
        headers: { Authorization: `Bearer ${key}` },
      });
      return res3.ok;

    case "google":
      const res4 = await fetch(
        `https://generativelanguage.googleapis.com/v1/models?key=${key}`
      );
      return res4.ok;

    default:
      return false;
  }
}
```

**Save button** (appears after successful validation):

```ts
{
  width: "100%",
  padding: "10px",
  background: theme.accent,
  color: theme.accentText,
  border: "none",
  borderRadius: 8,
  fontSize: 13,
  fontWeight: 450,
  cursor: "pointer",
  marginTop: 12,
}
```

"Save Key"

**On save:**

```ts
async function saveApiKey(provider: string, key: string) {
  const supabase = createClient();
  const { error } = await supabase.functions.invoke("store-api-key", {
    body: { provider, key },
  });

  if (error) {
    showToast("Failed to save key. Please try again.");
    return;
  }

  queryClient.invalidateQueries({ queryKey: ["api-keys"] });
  showToast("API key added successfully.");
  closeModal();
}
```

The `store-api-key` Edge Function encrypts the key using Supabase Vault (or AES-256 with the platform's encryption key) before inserting into the `api_keys` table. The function:

1. Encrypts the full key → `encrypted_key`  
2. Extracts last 4 characters → `key_hint` (e.g., "...x9Qf")  
3. Inserts record with status "active" and `last_validated_at = now()`

### Data Fetching 

```ts
const { data: apiKeys } = useQuery({
  queryKey: ["api-keys"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("api_keys")
      .select("id, provider, key_hint, status, last_validated_at, created_at")
      .order("created_at", { ascending: true });
    return data ?? [];
  },
});
```

### Empty State 

When no API keys exist:

- Key icon (28px, `textFaint`, marginBottom 12px)  
- "No API keys connected" (14px, weight 400, `text`)  
- "Connect your own API keys to unlock unlimited chats and access additional models." (12px, weight 300, `textMuted`, marginTop 6px, maxWidth 360px, lineHeight 1.5)  
- "Add API Key" button (accent bg, marginTop 16px)

---

## 13.6 Types Tab (Premium+) 

Instance Types are reusable configuration templates. When a user creates an Instance and selects a type, the type's defaults are applied as the third layer in the settings cascade (Part 7). This tab lets users manage their type templates.

### Tier Gate 

Types tab requires Premium or Pro tier. Free/Plus users see the standard gate content:

- Lock icon (20px)  
- "Unlock Instance Types"  
- "Create reusable configuration templates for your workspaces."  
- "Upgrade to Premium" button

### Tab Header 

- "Instance Types" (14px, weight 450, `text`, marginBottom 4px)  
- "Types provide default settings that all Instances of that type inherit." (12px, weight 300, `textMuted`)

### Type List 

Each type card:

```ts
{
  padding: "18px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
}
```

**Top row** (flex, justifyContent space-between, alignItems center, marginBottom 10px):

- Type name (15px, weight 450, `text`)  
- Instance count: "{n} instances" (12px, weight 300, `textMuted`, marginTop 2px)  
- "Edit Defaults" button (bordered, 11px, `textMuted`)

**Default settings chips** (flex, gap 8px, flexWrap wrap):

```ts
{
  padding: "3px 10px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  fontSize: 11,
  color: theme.textSec,
  fontWeight: 350,
}
```

Each chip: `{key}: {value}` where key is capitalized and in `textFaint`.

Example chips: "Voice: Professional", "Cleanup: Weekly", "Model: Claude Sonnet"

### Instance Count Query 

```ts
const { data: typeCounts } = useQuery({
  queryKey: ["instance-type-counts"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("instances")
      .select("type")
      .is("archived_at", null);

    // Count instances per type
    const counts: Record<string, number> = {};
    (data ?? []).forEach(inst => {
      counts[inst.type] = (counts[inst.type] || 0) + 1;
    });
    return counts;
  },
});
```

### Edit Type Defaults Modal 

Clicking "Edit Defaults" opens a modal:

Same modal shell (480px width).

**Header:** "Edit {type\_name} Defaults" (16px, weight 450\) \+ X close button

**Settings fields** (marginTop 16px, flex column, gap 14px):

Each setting field:

```ts
{
  display: "flex",
  flexDirection: "column",
  gap: 4,
}
```

- Label: (11px, weight 400, `textMuted`)  
- Input varies by type:

**Voice** (text input): Standard input field (13px, inputBg, border, borderRadius 8, padding 8px 12px).

**Tone** (text input): Same.

**Personality** (text input): Same.

**Cleanup Frequency** (select): Dropdown with options: Hourly, Daily, Weekly, Monthly, Never.

```ts
{
  padding: "8px 12px",
  background: theme.inputBg,
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  fontSize: 13,
  color: theme.text,
  outline: "none",
  cursor: "pointer",
}
```

**File Visibility** (select): Visible, Instance, Hidden.

**Greeting Style** (text input): Brief greeting that personas use when starting a conversation in this type.

**Default Personas** (multi-select):

```ts
{
  display: "flex",
  flexWrap: "wrap",
  gap: 6,
  marginTop: 4,
}
```

Currently assigned personas shown as removable chips (Av 16px \+ name 11px \+ X button). "Add Persona" button (bordered, 10px) opens a dropdown of the user's personas.

**Save button:** Full width accent button. On save:

```ts
async function updateTypeDefaults(typeId: string, settings: InstanceSettings) {
  const supabase = createClient();
  await supabase
    .from("instance_types")
    .update({ default_settings: settings })
    .eq("id", typeId);

  queryClient.invalidateQueries({ queryKey: ["instance-types"] });
  showToast("Type defaults updated.");
}
```

### Create New Type 

"Create New Type" button below the list (dashed border, Plus icon \+ text).

**Click:** Opens Create Type Modal:

**Header:** "Create Instance Type" (16px, weight 450\)

**Fields:**

- Name input (required): "Type Name" label \+ text input  
- Description input: "Description" label \+ text input  
- Same settings fields as Edit modal, all starting empty/default

**Create button:** accent bg, "Create Type". On save: inserts into `instance_types` table with `is_builtin: false`.

### Delete Type 

In the Edit modal, a "Delete Type" link appears at the bottom (12px, `#ef4444`).

- Built-in types (`is_builtin: true`) cannot be deleted — the link is hidden. Instead, "Reset to Defaults" link appears, which restores the original `default_settings`.  
- Custom types show the delete link. Click: confirmation modal: "Delete this type? Existing Instances using this type will keep their settings but lose the type association."

On confirm: deletes the `instance_types` row. Instances with that type name keep their current `settings` JSONB but the type label becomes orphaned (displayed as-is in the UI, no cascade lookup).

---

## 13.7 Cascade Tab (Premium+) 

The Cascade tab visualizes the four-level settings inheritance hierarchy defined in Part 7\. It shows how settings flow from platform defaults down to individual Instance overrides, making it clear where each active setting originates.

### Tier Gate 

Requires Premium+. Same gate pattern as Types tab.

### Tab Header 

- "Settings Cascade" (14px, weight 450, `text`, marginBottom 4px)  
- "Each layer inherits from above and can override. Lower layers take precedence." (12px, weight 300, `textMuted`)

### Cascade Layers 

4 collapsible layer cards with downward arrows between them:

```ts
{
  display: "flex",
  flexDirection: "column",
  gap: 0,
}
```

Each layer card:

**Header row** (clickable to expand/collapse):

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 10,
  padding: "14px 16px",
  background: theme.surfaceAlt,
  borderRadius: isExpanded ? "10px 10px 0 0" : 10,
  cursor: "pointer",
}
```

- Number circle (24px diameter, accent bg, accentText, borderRadius 50%, fontSize 11, fontWeight 600, flexShrink 0\)  
- Layer info (flex: 1):  
  - Name (13px, weight 450, `text`)  
  - Description (11px, weight 300, `textMuted`)  
- Chevron: ChevronRight (14px, `textFaint`), rotates 90° when expanded. `transition: transform 0.15s`.

**Expanded content:**

```ts
{
  padding: "12px 16px 12px 50px",
  background: theme.surfaceAlt,
  borderRadius: "0 0 10px 10px",
  borderTop: `1px solid ${theme.borderSubtle}`,
}
```

Each setting item in the layer:

```ts
{
  padding: "5px 0",
  fontSize: 12,
  color: theme.textSec,
  fontWeight: 350,
  display: "flex",
  alignItems: "center",
  gap: 6,
}
```

- Small dot (4px diameter, `textFaint`, borderRadius 2, flexShrink 0\)  
- Setting text: "{key}: {value}"

**Arrow between layers** (except after the last):

```ts
{
  display: "flex",
  justifyContent: "center",
  padding: "4px 0",
}
```

ChevronDown icon (14px, `textFaint`)

### 4 Cascade Layers 

| \# | Name | Description | Data Source | Editable Here? |
| :---- | :---- | :---- | :---- | :---- |
| 1 | Platform Defaults | Base defaults hardcoded by aiConnected | Hardcoded constants | No (read-only) |
| 2 | Global Instance Settings | Your defaults for all Instances | `profiles.preferences.global_instance_settings` | Yes (inline edit) |
| 3 | Type Template | Overrides from the Instance's type | `instance_types.default_settings` | Yes (links to Types tab) |
| 4 | Instance Overrides | Custom settings on individual Instances | `instances.settings` | Yes (links to Instance detail) |

**Layer 1: Platform Defaults** (always read-only):

| Setting | Default Value |
| :---- | :---- |
| Greeting style | Warm |
| Max tokens | 4096 |
| Voice | Neutral |
| Cleanup frequency | Weekly |
| File visibility | Visible |
| Storage location | Local |

**Layer 2: Global Instance Settings** (editable):

Each setting item is clickable. Clicking opens an inline editor:

```ts
{
  padding: "6px 0",
  display: "flex",
  alignItems: "center",
  gap: 8,
}
```

- Setting name (12px, weight 400, `textMuted`, width 120px)  
- Editable input/select (12px, inputBg, border, borderRadius 6, padding 4px 8px, flex: 1\)  
- Save icon button (Check, 12px, accent, cursor pointer, appears on change)  
- Cancel icon button (X, 12px, textFaint, cursor pointer, appears on change)

On save: updates `profiles.preferences.global_instance_settings.{key}`.

**Layer 3: Type Template** — shows a summary of the selected type's defaults. A "Edit in Types tab" link (10px, accent) navigates to the Types tab.

**Layer 4: Instance Overrides** — shows that each Instance can have custom settings. A "Manage in Instance settings" note (10px, `textFaint`).

### Resolved Settings Preview 

Below the cascade layers, a preview card shows the final resolved settings for a specific Instance:

```ts
{
  marginTop: 20,
  padding: "14px 16px",
  background: theme.surfaceAlt,
  borderRadius: 10,
}
```

**Title:** "What's active for "{instance\_name}"?" (12px, weight 450, `text`, marginBottom 6px)

The title uses the user's most recently active Instance. If no Instances exist, shows "What's active for new Instances?" and uses only layers 1–2.

**Resolved settings** (11px, weight 350, `textSec`, lineHeight 1.6):

Each setting displayed as: "{key}: {value} (from {source\_layer})"

Example: "Voice: Professional (from Type: Business) · Personality: Collaborative (Custom override) · Storage: Local \+ Drive (Custom override) · Cleanup: Weekly (from Global)"

Source labels use different colors:

- "from Global" → `textFaint`  
- "from Type: X" → `textMuted`  
- "Custom override" → accent color

**Resolved settings computation:**

```ts
function resolveInstanceSettings(instanceId: string): ResolvedSetting[] {
  const platform = PLATFORM_DEFAULTS;
  const global = profile.preferences.global_instance_settings;
  const instance = instances.find(i => i.id === instanceId);
  const typeTemplate = instanceTypes.find(
    t => t.name === instance?.type
  )?.default_settings ?? {};
  const instanceOverrides = instance?.settings ?? {};

  const allKeys = new Set([
    ...Object.keys(platform),
    ...Object.keys(global),
    ...Object.keys(typeTemplate),
    ...Object.keys(instanceOverrides),
  ]);

  return Array.from(allKeys).map(key => {
    if (instanceOverrides[key] !== undefined) {
      return { key, value: instanceOverrides[key], source: "Custom override" };
    }
    if (typeTemplate[key] !== undefined) {
      return { key, value: typeTemplate[key], source: `Type: ${instance?.type}` };
    }
    if (global[key] !== undefined) {
      return { key, value: global[key], source: "Global" };
    }
    return { key, value: platform[key], source: "Platform default" };
  });
}

interface ResolvedSetting {
  key: string;
  value: any;
  source: string;
}
```

---

## 13.8 Learned Rules Tab (Plus+) 

The Learned Rules tab displays instruction memory — behavioral rules that Cipher has extracted from user interactions. These rules influence how personas behave in future conversations. Users can review, toggle, edit, and delete rules.

### Tier Gate 

Requires Plus+. Free users see the standard gate content with "Upgrade to Plus" button.

### Tab Header 

- "Instruction Memory" (14px, weight 450, `text`, marginBottom 4px)  
- "Rules learned from your interactions. Toggle or remove rules that aren't helping." (12px, weight 300, `textMuted`)

### Filter Pills 

```ts
{
  display: "flex",
  gap: 5,
  marginBottom: 20,
}
```

3 filter options: "All", "Active", "Inactive". Each pill:

```ts
{
  padding: "4px 11px",
  borderRadius: 20,
  fontSize: 11,
  border: "none",
  cursor: "pointer",
  background: isActive ? theme.accent : theme.surfaceAlt,
  color: isActive ? theme.accentText : theme.textMuted,
}
```

Default: "All". Filtering is client-side.

### Rule Count 

Below filters (10px, weight 300, `textFaint`): "Showing {n} rules" or "{n} active of {total} rules" when filtered.

### Rule List 

Each rule item:

```ts
{
  padding: "16px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
  display: "flex",
  gap: 12,
  alignItems: "flex-start",
}
```

**Left: Toggle** (marginTop 2px, flexShrink 0):

Active: ToggleRight (18px, `dot.active` green) Inactive: ToggleLeft (18px, `textFaint`)

Click toggles `instruction_memory.active`:

```ts
async function toggleRule(ruleId: string, currentActive: boolean) {
  const supabase = createClient();
  await supabase
    .from("instruction_memory")
    .update({ active: !currentActive })
    .eq("id", ruleId);
  queryClient.invalidateQueries({ queryKey: ["instruction-memory"] });
}
```

**Center: Rule content (flex: 1\)**

Rule text:

```ts
{
  fontSize: 13,
  color: rule.active ? theme.text : theme.textMuted,
  fontWeight: 400,
  lineHeight: 1.5,
  textDecoration: rule.active ? "none" : "line-through",
  opacity: rule.active ? 1 : 0.6,
}
```

Metadata row (marginTop 6px, flex, alignItems center, gap 8px, flexWrap wrap):

- Scope badge:

```ts
{
  fontSize: 10,
  padding: "2px 8px",
  background: theme.surfaceAlt,
  borderRadius: 8,
  color: theme.textMuted,
}
```

Value: `rule.scope === 'global' ? 'Global' : rule.scope_target_name` prefixed with scope type. Examples: "Global", "Instance: Client Website Redesign", "Type: Business".

- Source (10px, `textFaint`): "from {source\_description}". Links to the source chat if `source_chat_id` is not null.  
- Date (10px, `textFaint`): relative or short date of `created_at`

**Right: Action buttons** (flex, gap 4px, flexShrink 0):

Edit button:

```ts
{
  background: "none",
  border: "none",
  color: theme.textFaint,
  cursor: "pointer",
  display: "flex",
  padding: 3,
}
// Edit3 icon (12px)
```

Delete button:

```ts
{
  background: "none",
  border: "none",
  color: theme.textFaint,
  cursor: "pointer",
  display: "flex",
  padding: 3,
}
// Trash2 icon (12px)
// Hover: color #ef4444
```

### Edit Rule Inline 

Clicking the Edit button transforms the rule text into an editable textarea:

```ts
{
  width: "100%",
  minHeight: 50,
  padding: "8px 10px",
  background: theme.inputBg,
  border: `1px solid ${theme.border}`,
  borderRadius: 8,
  fontSize: 13,
  color: theme.text,
  fontWeight: 400,
  lineHeight: 1.5,
  resize: "vertical",
  outline: "none",
  fontFamily: "inherit",
}
```

Below textarea (flex, gap 6px, marginTop 6px):

- "Save" button (accent bg, accentText, borderRadius 6, padding 4px 12px, fontSize 10\)  
- "Cancel" button (bordered, textMuted, borderRadius 6, padding 4px 12px, fontSize 10\)

On save:

```ts
async function updateRule(ruleId: string, newText: string) {
  const supabase = createClient();
  await supabase
    .from("instruction_memory")
    .update({ rule: newText })
    .eq("id", ruleId);
  queryClient.invalidateQueries({ queryKey: ["instruction-memory"] });
}
```

### Delete Rule 

Clicking the Trash button opens inline confirmation (same pattern as API key removal):

- "Delete this rule? This cannot be undone." (11px, weight 350, `textMuted`)  
- "Cancel" \+ "Delete" buttons

On confirm: hard-deletes the row from `instruction_memory`.

### Add Rule Manually 

Below the rule list, an "Add Rule" button (dashed border, Plus icon \+ text).

**Click:** Expands an inline form above the button:

```ts
{
  padding: "14px 0",
  borderBottom: `1px solid ${theme.borderSubtle}`,
}
```

- Rule text area (same as edit textarea, placeholder: "Enter a behavioral rule...")  
    
- Scope selector (flex, gap 6px, marginTop 8px):  
    
  - "Global" pill (default active)  
  - "Type: ..." dropdown (shows user's instance types)  
  - "Instance: ..." dropdown (shows user's instances)


- "Add Rule" button (accent bg, marginTop 10px) \+ "Cancel" button (bordered)

On add:

```ts
async function addRule(rule: string, scope: string, scopeTargetId: string | null) {
  const supabase = createClient();
  const scopeName = scope === "global" ? ""
    : scope === "type"
      ? instanceTypes.find(t => t.id === scopeTargetId)?.name ?? ""
      : instances.find(i => i.id === scopeTargetId)?.name ?? "";

  await supabase.from("instruction_memory").insert({
    user_id: userId,
    rule,
    scope,
    scope_target_id: scopeTargetId,
    scope_target_name: scopeName,
    source_description: "Manual entry",
    active: true,
  });

  queryClient.invalidateQueries({ queryKey: ["instruction-memory"] });
}
```

### How Cipher Creates Rules (Reference) 

This section documents the creation path for automatic rules — the primary source of instruction memory. This process is implemented in Part 14 (Cipher) but referenced here for completeness.

During a conversation, Cipher monitors for behavioral patterns:

1. **Explicit instructions:** User says "Always do X" or "Never do Y" → Cipher creates a rule with the exact instruction.  
2. **Correction patterns:** User corrects a persona's behavior multiple times in the same way → Cipher infers a rule after the third correction.  
3. **Preference patterns:** User consistently chooses one option over another → Cipher creates a preference rule with "weak" confidence initially, upgrading to "strong" after 5+ consistent observations.

Each auto-created rule includes `source_chat_id` (the chat where it was detected) and `source_description` (the chat title or a brief context string).

Auto-created rules default to `active: true`. Users are notified via a non-blocking toast: "Learned: {rule\_summary}" with an "Undo" action that deactivates the rule.

### Data Fetching 

```ts
const { data: rules } = useQuery({
  queryKey: ["instruction-memory"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("instruction_memory")
      .select("*")
      .order("created_at", { ascending: false });
    return data ?? [];
  },
});
```

### Empty State 

When no instruction memory rules exist:

- BookOpen icon (28px, `textFaint`, marginBottom 12px)  
- "No learned rules yet" (14px, weight 400, `text`)  
- "As you interact with your Personas, Cipher will learn behavioral rules from your patterns and preferences." (12px, weight 300, `textMuted`, marginTop 6px, maxWidth 380px, lineHeight 1.5, margin auto)  
- "Add Rule Manually" button (bordered, 12px, marginTop 16px)

---

## 13.9 Responsive Behavior 

### Mobile Layout 

On mobile (\< 768px):

- Tab bar scrolls horizontally (`overflowX: auto`) with no wrapping  
- General tab rows stack icon above content when needed  
- Model role rows use `flexDirection: column` — role label, model chips, and provider stack vertically  
- API key rows: action buttons wrap below the key info  
- Modals use `width: 90%` instead of fixed pixel widths

### Tab bar scroll indicator 

On mobile, when tabs overflow:

```ts
{
  WebkitOverflowScrolling: "touch",
  scrollbarWidth: "none",
}
// Hide scrollbar but allow horizontal scroll
```

A subtle gradient fade on the right edge indicates more tabs are scrollable (CSS pseudo-element: `linear-gradient(to left, theme.bg, transparent)`, width 24px).

---

## 13.10 Settings Persistence & Propagation 

### Immediate Save Pattern 

All settings save immediately on change (no global "Save" button pattern). The update flow:

1. User changes a setting  
2. Optimistic update: UI reflects the change immediately  
3. Supabase mutation fires in background  
4. React Query cache invalidated on success  
5. On error: revert optimistic update, show error toast

### Cross-Screen Propagation 

Settings changes propagate to other screens via React Query:

| Setting Changed | Affected Screens | Propagation |
| :---- | :---- | :---- |
| Interface mode | All screens | `queryKey: ["profile"]` invalidation triggers re-render of all mode-dependent components |
| Voice & tone | Chat (system prompts) | Cipher reads profile preferences on next message |
| Model roles | Chat (model routing) | Cipher reads role assignments on next message |
| API keys | Chat, Models tab | `queryKey: ["api-keys"]` invalidation; model availability recalculated |
| Cleanup settings | Background (Cipher cleanup) | Cipher reads preferences on next scheduled run |
| Instruction memory | Chat (system prompts) | Cipher includes active rules in context assembly |

### Optimistic Update Example 

```ts
async function togglePreference(key: string) {
  const newValue = !currentPreferences[key];

  // Optimistic update
  queryClient.setQueryData(["profile"], (old: Profile) => ({
    ...old,
    preferences: { ...old.preferences, [key]: newValue },
  }));

  const supabase = createClient();
  const { error } = await supabase
    .from("profiles")
    .update({
      preferences: { ...currentPreferences, [key]: newValue },
    })
    .eq("id", userId);

  if (error) {
    // Revert
    queryClient.setQueryData(["profile"], (old: Profile) => ({
      ...old,
      preferences: { ...old.preferences, [key]: !newValue },
    }));
    showToast("Failed to save setting.");
  }
}
```

---

## 13.11 Keyboard Accessibility 

- All tabs are focusable (tabIndex 0), activated by Enter/Space  
- Toggle switches are focusable and toggled by Enter/Space  
- Modal focus traps: Tab cycles through modal elements only while open; Escape closes the modal; focus returns to the trigger element on close  
- Segmented buttons (Interface Mode, Cleanup Frequency) support arrow key navigation between options  
- All interactive elements have visible focus indicators (outline: `2px solid ${theme.accent}`, outlineOffset: 2px)  
- The "Delete Account" confirmation input auto-focuses when the modal opens

---

*End of Part 13\. Proceed to Part 14: Cipher Orchestration Engine.*

# PART 14: CIPHER ORCHESTRATION ENGINE 

---

Cipher is the invisible intelligence layer of aiConnected. It is never user-addressable, never has a name or avatar in chat, and never speaks to the user directly. Cipher is the orchestration engine that intercepts every user message, decides which persona should respond, selects the optimal model, assembles the context window, monitors for memories to extract, enforces persona boundaries, manages background maintenance, and coordinates multi-persona conversations. Every interaction flows through Cipher — it is the single entry point between the user and the AI.

This part defines the complete server-side implementation of Cipher across 4 primary Edge Functions and 3 background jobs. It references database tables defined in Parts 2, 4, 5, 7, 9, 10, and 13\.

---

## 14.1 Architecture Overview 

### Core Responsibilities 

| Responsibility | Description | When It Runs |
| :---- | :---- | :---- |
| **Message Routing** | Determine which persona responds to each message | Every user message |
| **Model Selection** | Choose the AI model based on role assignments and message intent | Every user message |
| **Context Assembly** | Build the optimal prompt from persona identity, memories, conversation history, instance context, and instruction memory | Every user message |
| **Skill Validation** | Verify the selected persona can handle the request; redirect if not | Every user message |
| **Safety Enforcement** | Content filtering, boundary checking, rate limiting | Every user message |
| **Memory Extraction** | Analyze conversation exchanges and extract knowledge to CogniGraph | After every AI response |
| **Instruction Learning** | Detect behavioral patterns and create instruction\_memory rules | After every AI response |
| **Auto-Rename** | Generate better chat titles after context is established | After 6+ messages |
| **Suggested Move** | Match chats to existing Instances and suggest organization | After 10+ messages |
| **Background Cleanup** | Flag stale content, suggest renames for old chats, detect drift | Scheduled (configurable) |
| **Health Monitoring** | Track persona health metrics, detect memory conflicts | Scheduled (daily) |
| **Multi-Persona Coordination** | Manage turn-taking and handoff in multi-persona conversations | During team runs and multi-persona chats |
| **Server-Driven UI** | Decide when to inject structured content blocks into responses | During response processing |

### Edge Function Architecture 

```
┌─────────────────────────────────────────────────────────────────────┐
│                        User sends message                          │
└───────────────────────────────┬─────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    cipher-route (synchronous)                       │
│                                                                     │
│  1. Auth validation                                                 │
│  2. Rate limit check                                                │
│  3. Insert user message                                             │
│  4. Route to persona  ─────────────────────────┐                    │
│  5. Select model                                │                   │
│  6. Validate persona skills                     │ Redirect if       │
│  7. Assemble context window                     │ skill mismatch    │
│  8. Call AI model (stream response)             │                   │
│  9. Insert persona response                     │                   │
│ 10. Dispatch post-response tasks ──────┐        │                   │
└────────────────────────────────────────┼────────┘                   │
                                         │                            │
                                         ▼                            │
┌─────────────────────────────────────────────────────────────────────┐
│                  cipher-memory (asynchronous)                       │
│                                                                     │
│  1. Extract memories from exchange                                  │
│  2. Generate embeddings                                             │
│  3. Check for conflicts with existing memories                      │
│  4. Store in persona_memories                                       │
│  5. Detect instruction patterns → instruction_memory                │
│  6. Auto-rename check                                               │
│  7. Suggested move check                                            │
│  8. Activity log entry                                              │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│               cipher-cleanup (scheduled: configurable)              │
│                                                                     │
│  1. Scan old chats for rename opportunities                         │
│  2. Suggest Instance moves for unorganized chats                    │
│  3. Flag stale memories (>90 days unreferenced)                     │
│  4. Decay weak-confidence memories                                  │
│  5. Delete expired temporary skills                                 │
│  6. Purge soft-deleted chats past retention period                  │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│                 cipher-health (scheduled: daily)                    │
│                                                                     │
│  1. Compute persona health metrics                                  │
│  2. Detect persona drift                                            │
│  3. Identify memory conflicts                                       │
│  4. Insert health snapshots                                         │
│  5. Update persona mood based on recent activity                    │
└─────────────────────────────────────────────────────────────────────┘
```

### Invocation Patterns 

| Function | Trigger | Latency Requirement |
| :---- | :---- | :---- |
| `cipher-route` | HTTP POST from client on message send | Synchronous — user waits for streaming response |
| `cipher-memory` | Invoked by `cipher-route` after response completes | Asynchronous — fire-and-forget, no user wait |
| `cipher-cleanup` | Supabase Cron / pg\_cron on user's `cleanup_frequency` | Background — runs in minutes, no user awareness |
| `cipher-health` | Supabase Cron — daily at 03:00 UTC | Background — no user awareness |

---

## 14.2 cipher-route: Message Processing Pipeline 

This is the primary Edge Function. Every user message enters here. It is the synchronous hot path that the user waits for.

### Entry Point 

```ts
// supabase/functions/cipher-route/index.ts

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import OpenAI from "https://esm.sh/openai@4";

interface RouteRequest {
  chat_id: string;
  content: string;
  directed_persona_id: string | null;
  model_override: string | null;
  attachments: Attachment[];
}

interface Attachment {
  file_id: string;
  name: string;
  type: string;
  url: string;
}

serve(async (req: Request) => {
  // --- Step 1: Auth ---
  const authHeader = req.headers.get("Authorization");
  if (!authHeader) return new Response("Unauthorized", { status: 401 });

  const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
  const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
  const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!;

  // Anon client for auth verification
  const anonClient = createClient(supabaseUrl, supabaseAnonKey, {
    global: { headers: { Authorization: authHeader } },
  });

  const { data: { user }, error: authError } = await anonClient.auth.getUser();
  if (authError || !user) return new Response("Unauthorized", { status: 401 });

  // Service client for privileged operations
  const serviceClient = createClient(supabaseUrl, supabaseServiceKey);

  const body: RouteRequest = await req.json();

  // --- Step 2: Rate limit ---
  const allowed = await checkRateLimit(serviceClient, user.id);
  if (!allowed) {
    return new Response(
      JSON.stringify({ error: "rate_limit", message: "Daily message limit reached." }),
      { status: 429, headers: { "Content-Type": "application/json" } }
    );
  }

  // --- Step 3: Load context data in parallel ---
  const [profile, chat, participants, recentMessages, instanceData] = await Promise.all([
    loadProfile(serviceClient, user.id),
    loadChat(serviceClient, body.chat_id, user.id),
    loadParticipants(serviceClient, body.chat_id),
    loadRecentMessages(serviceClient, body.chat_id, 50),
    loadInstanceContext(serviceClient, body.chat_id),
  ]);

  if (!chat) return new Response("Chat not found", { status: 404 });

  // --- Step 4: Insert user message ---
  const userMessage = await insertUserMessage(serviceClient, {
    chat_id: body.chat_id,
    content: body.content,
    attachments: body.attachments,
    user_id: user.id,
  });

  // --- Step 5: Route to persona ---
  const routing = await routeToPersona(serviceClient, {
    message: body.content,
    directedPersonaId: body.directed_persona_id,
    participants,
    recentMessages,
    instanceData,
    profile,
  });

  // --- Step 6: Validate persona skills ---
  const validation = await validateSkills(serviceClient, routing.persona, body.content);
  if (validation.redirect) {
    routing.persona = validation.redirectTo;
    routing.reason = "skill_redirect";
    routing.note = validation.redirectNote;
  }

  // --- Step 7: Select model ---
  const modelId = selectModel(
    body.model_override,
    routing.persona,
    routing.intentCategory,
    profile,
  );

  // --- Step 8: Assemble context window ---
  const context = await assembleContext(serviceClient, {
    persona: routing.persona,
    memories: true,
    recentMessages,
    instanceData,
    userMessage: body.content,
    attachments: body.attachments,
    profile,
    modelId,
  });

  // --- Step 9: Call AI model (streaming) ---
  const apiKey = await resolveApiKey(serviceClient, user.id, modelId, profile);

  return streamResponse(apiKey, modelId, context, {
    serviceClient,
    chatId: body.chat_id,
    personaId: routing.persona.id,
    personaName: routing.persona.name,
    routingReason: routing.reason,
    routingNote: routing.note,
    userId: user.id,
    userMessageId: userMessage.id,
  });
});
```

### Step 2: Rate Limiting 

Rate limits are enforced per user per day, based on tier. BYOK users have no rate limit.

```ts
async function checkRateLimit(
  client: SupabaseClient,
  userId: string
): Promise<boolean> {
  // Check BYOK status first
  const { data: apiKeys } = await client
    .from("api_keys")
    .select("id")
    .eq("user_id", userId)
    .eq("status", "active")
    .limit(1);

  if (apiKeys && apiKeys.length > 0) return true; // BYOK = unlimited

  // Load profile for tier
  const { data: profile } = await client
    .from("profiles")
    .select("tier")
    .eq("id", userId)
    .single();

  const DAILY_LIMITS: Record<string, number> = {
    free: 15,
    plus: 150,
    premium: 1000,
    pro: 5000,
  };

  const limit = DAILY_LIMITS[profile?.tier ?? "free"] ?? 15;

  // Count today's messages
  const todayStart = new Date();
  todayStart.setUTCHours(0, 0, 0, 0);

  const { count } = await client
    .from("messages")
    .select("id", { count: "exact", head: true })
    .eq("sender_type", "user")
    .gte("created_at", todayStart.toISOString())
    .in(
      "chat_id",
      client.from("chats").select("id").eq("user_id", userId)
    );

  return (count ?? 0) < limit;
}
```

**Daily message limits:**

| Tier | Daily Limit | BYOK Override |
| :---- | :---- | :---- |
| Free | 15 messages | Unlimited |
| Plus | 150 messages | Unlimited |
| Premium | 1,000 messages | Unlimited |
| Pro | 5,000 messages | Unlimited |

### Step 3: Parallel Data Loading 

All context data is loaded in parallel using `Promise.all` to minimize latency:

```ts
async function loadProfile(client: SupabaseClient, userId: string) {
  const { data } = await client
    .from("profiles")
    .select("*, subscriptions(tier, status)")
    .eq("id", userId)
    .single();
  return data;
}

async function loadChat(client: SupabaseClient, chatId: string, userId: string) {
  const { data } = await client
    .from("chats")
    .select("*")
    .eq("id", chatId)
    .eq("user_id", userId)
    .is("deleted_at", null)
    .single();
  return data;
}

async function loadParticipants(client: SupabaseClient, chatId: string) {
  const { data } = await client
    .from("chat_participants")
    .select("persona_id, personas(*)")
    .eq("chat_id", chatId)
    .is("removed_at", null);
  return (data ?? []).map(d => d.personas).filter(Boolean);
}

async function loadRecentMessages(
  client: SupabaseClient,
  chatId: string,
  limit: number
) {
  const { data } = await client
    .from("messages")
    .select("*")
    .eq("chat_id", chatId)
    .order("created_at", { ascending: false })
    .limit(limit);
  return (data ?? []).reverse(); // Chronological order
}

async function loadInstanceContext(client: SupabaseClient, chatId: string) {
  const { data: chat } = await client
    .from("chats")
    .select("instance_id")
    .eq("id", chatId)
    .single();

  if (!chat?.instance_id) return null;

  const { data: instance } = await client
    .from("instances")
    .select("*, instance_personas(persona_id)")
    .eq("id", chat.instance_id)
    .single();

  return instance;
}
```

### Step 4: Insert User Message 

```ts
async function insertUserMessage(
  client: SupabaseClient,
  params: {
    chat_id: string;
    content: string;
    attachments: Attachment[];
    user_id: string;
  }
) {
  const { data } = await client
    .from("messages")
    .insert({
      chat_id: params.chat_id,
      sender_type: "user",
      sender_id: null,
      sender_name: "You",
      content: params.content,
      has_link: containsUrl(params.content),
      has_media: params.attachments.length > 0,
      metadata: params.attachments.length > 0
        ? { attachments: params.attachments }
        : {},
    })
    .select()
    .single();

  // Update chat timestamp
  await client
    .from("chats")
    .update({ updated_at: new Date().toISOString() })
    .eq("id", params.chat_id);

  return data;
}

function containsUrl(text: string): boolean {
  return /https?:\/\/[^\s]+/.test(text);
}
```

---

## 14.3 Routing Algorithm 

The routing algorithm determines which persona responds. It uses a priority cascade — each level is tried in order, and the first match wins.

### Priority Cascade 

```ts
interface RoutingResult {
  persona: Persona;
  reason: "mention" | "skill_match" | "context" | "instance_default" | "global_default" | "skill_redirect";
  note: string;          // Human-readable routing explanation
  intentCategory: string; // Detected intent category for model selection
  confidence: number;    // 0-1 routing confidence
}

async function routeToPersona(
  client: SupabaseClient,
  params: {
    message: string;
    directedPersonaId: string | null;
    participants: Persona[];
    recentMessages: Message[];
    instanceData: Instance | null;
    profile: Profile;
  }
): Promise<RoutingResult> {
  // Priority 1: Explicit @mention / directed persona
  if (params.directedPersonaId) {
    const persona = params.participants.find(
      p => p.id === params.directedPersonaId
    );
    if (persona) {
      return {
        persona,
        reason: "mention",
        note: `Directed to ${persona.name} by user.`,
        intentCategory: classifyIntent(params.message),
        confidence: 1.0,
      };
    }
  }

  // Priority 2: Skill matching
  const skillMatch = await matchBySkills(client, params.message, params.participants);
  if (skillMatch && skillMatch.confidence >= 0.6) {
    return {
      persona: skillMatch.persona,
      reason: "skill_match",
      note: `Routed to ${skillMatch.persona.name} — best skill match for "${skillMatch.matchedSkill}" (${Math.round(skillMatch.confidence * 100)}%).`,
      intentCategory: skillMatch.intentCategory,
      confidence: skillMatch.confidence,
    };
  }

  // Priority 3: Conversation context (continuation bias)
  const contextPersona = inferFromContext(params.recentMessages, params.participants);
  if (contextPersona) {
    return {
      persona: contextPersona,
      reason: "context",
      note: `Continuing with ${contextPersona.name} (active in conversation).`,
      intentCategory: classifyIntent(params.message),
      confidence: 0.7,
    };
  }

  // Priority 4: Instance default
  if (params.instanceData) {
    const primaryPersonaId = params.instanceData.instance_personas?.[0]?.persona_id;
    if (primaryPersonaId) {
      const persona = params.participants.find(p => p.id === primaryPersonaId);
      if (persona) {
        return {
          persona,
          reason: "instance_default",
          note: `Using ${persona.name} — primary persona for this workspace.`,
          intentCategory: classifyIntent(params.message),
          confidence: 0.5,
        };
      }
    }
  }

  // Priority 5: Global default (first participant, or last-used persona)
  const defaultPersona = params.participants[0];
  if (defaultPersona) {
    return {
      persona: defaultPersona,
      reason: "global_default",
      note: `Using ${defaultPersona.name} — default participant.`,
      intentCategory: classifyIntent(params.message),
      confidence: 0.3,
    };
  }

  // Fallback: should not reach here (every chat has at least one participant)
  throw new Error("No persona available for routing");
}
```

### Skill Matching 

Skill matching compares the user's message against each participant persona's skill set. This is a lightweight classification — not a full AI call — using keyword extraction and fuzzy matching.

```ts
interface SkillMatchResult {
  persona: Persona;
  matchedSkill: string;
  confidence: number;
  intentCategory: string;
}

async function matchBySkills(
  client: SupabaseClient,
  message: string,
  participants: Persona[]
): Promise<SkillMatchResult | null> {
  if (participants.length <= 1) return null; // Only one persona, no matching needed

  // Load skills for all participants
  const personaIds = participants.map(p => p.id);
  const { data: allSkills } = await client
    .from("persona_skills")
    .select("persona_id, name, category, level")
    .in("persona_id", personaIds);

  if (!allSkills || allSkills.length === 0) return null;

  // Extract intent keywords from the message
  const intentKeywords = extractIntentKeywords(message);
  const intentCategory = classifyIntent(message);

  // Score each persona
  const scores: { persona: Persona; score: number; matchedSkill: string }[] = [];

  for (const persona of participants) {
    const skills = allSkills.filter(s => s.persona_id === persona.id);
    let bestScore = 0;
    let bestSkill = "";

    for (const skill of skills) {
      const skillScore = computeSkillRelevance(
        skill.name,
        skill.category,
        skill.level,
        intentKeywords,
        intentCategory
      );
      if (skillScore > bestScore) {
        bestScore = skillScore;
        bestSkill = skill.name;
      }
    }

    // Boost from persona role if it matches intent
    const roleBoost = roleMatchesIntent(persona.role, intentCategory) ? 0.15 : 0;

    scores.push({
      persona,
      score: Math.min(bestScore + roleBoost, 1.0),
      matchedSkill: bestSkill,
    });
  }

  // Sort by score descending
  scores.sort((a, b) => b.score - a.score);

  if (scores.length === 0 || scores[0].score < 0.3) return null;

  // Check if top two scores are too close (ambiguous)
  if (scores.length >= 2 && scores[0].score - scores[1].score < 0.1) {
    return null; // Ambiguous — fall through to context matching
  }

  return {
    persona: scores[0].persona,
    matchedSkill: scores[0].matchedSkill,
    confidence: scores[0].score,
    intentCategory,
  };
}
```

### Intent Classification 

Intent classification categorizes the user's message into one of the model role categories (from Part 13). This is used for both skill matching and model selection.

````ts
// Lightweight regex + keyword-based classifier
// No AI call required — runs in <1ms

const INTENT_PATTERNS: { category: string; patterns: RegExp[]; keywords: string[] }[] = [
  {
    category: "coding",
    patterns: [
      /\b(code|debug|fix|implement|refactor|function|class|component|api|endpoint|deploy|typescript|javascript|python|sql|css|html|react|next\.?js)\b/i,
      /```[\s\S]*```/,
    ],
    keywords: ["code", "bug", "error", "compile", "syntax", "algorithm", "script", "variable", "loop", "database", "query", "import", "export"],
  },
  {
    category: "creative",
    patterns: [
      /\b(design|mockup|layout|wireframe|color|font|typography|visual|aesthetic|logo|brand|illustration|image|photo|generate.*image)\b/i,
    ],
    keywords: ["design", "creative", "visual", "style", "palette", "composition", "artwork", "brainstorm", "ideate"],
  },
  {
    category: "research",
    patterns: [
      /\b(research|analyze|compare|find|investigate|source|study|data|statistics|evidence|paper|journal|report)\b/i,
    ],
    keywords: ["research", "analysis", "compare", "investigate", "data", "evidence", "findings", "source", "study"],
  },
  {
    category: "writing",
    patterns: [
      /\b(write|draft|edit|proofread|copy|article|blog|email|letter|proposal|summary|rewrite|tone)\b/i,
    ],
    keywords: ["write", "draft", "edit", "copy", "prose", "paragraph", "document", "email", "article", "blog"],
  },
  {
    category: "planning",
    patterns: [
      /\b(plan|strategy|roadmap|timeline|milestone|sprint|schedule|organize|prioritize|breakdown|task|step)\b/i,
    ],
    keywords: ["plan", "strategy", "timeline", "milestone", "organize", "prioritize", "roadmap", "project"],
  },
  {
    category: "reasoning",
    patterns: [
      /\b(explain|why|how.*work|reason|logic|calculate|math|formula|theorem|proof|derive|deduce)\b/i,
    ],
    keywords: ["reason", "logic", "calculate", "math", "explain", "why", "analyze", "deduce", "prove"],
  },
  {
    category: "quick",
    patterns: [
      /^(what|who|when|where|how much|translate|define|yes|no|ok|thanks|hello|hi)\b/i,
    ],
    keywords: ["translate", "define", "lookup", "quick", "simple"],
  },
];

function classifyIntent(message: string): string {
  let bestCategory = "quick"; // Default for short/ambiguous messages
  let bestScore = 0;

  for (const { category, patterns, keywords } of INTENT_PATTERNS) {
    let score = 0;

    // Pattern matches (weighted higher)
    for (const pattern of patterns) {
      if (pattern.test(message)) score += 3;
    }

    // Keyword matches
    const messageLower = message.toLowerCase();
    for (const keyword of keywords) {
      if (messageLower.includes(keyword)) score += 1;
    }

    // Length heuristic: very short messages likely "quick"
    if (message.length < 30 && category === "quick") score += 2;

    if (score > bestScore) {
      bestScore = score;
      bestCategory = category;
    }
  }

  return bestCategory;
}

function extractIntentKeywords(message: string): string[] {
  const stopWords = new Set([
    "the", "a", "an", "is", "are", "was", "were", "be", "been",
    "being", "have", "has", "had", "do", "does", "did", "will",
    "would", "could", "should", "may", "might", "can", "shall",
    "to", "of", "in", "for", "on", "with", "at", "by", "from",
    "as", "into", "through", "during", "before", "after", "above",
    "below", "between", "and", "but", "or", "nor", "not", "so",
    "yet", "both", "either", "neither", "each", "every", "all",
    "this", "that", "these", "those", "i", "me", "my", "you",
    "your", "it", "its", "we", "our", "they", "them", "their",
    "what", "which", "who", "whom", "where", "when", "how",
    "please", "thanks", "thank", "hello", "hi", "hey",
  ]);

  return message
    .toLowerCase()
    .replace(/[^\w\s]/g, "")
    .split(/\s+/)
    .filter(w => w.length > 2 && !stopWords.has(w));
}
````

### Skill Relevance Scoring 

```ts
function computeSkillRelevance(
  skillName: string,
  skillCategory: string,
  skillLevel: number,
  intentKeywords: string[],
  intentCategory: string
): number {
  let score = 0;
  const skillWords = skillName.toLowerCase().split(/\s+/);

  // Direct keyword overlap
  for (const keyword of intentKeywords) {
    for (const skillWord of skillWords) {
      if (skillWord === keyword) {
        score += 0.4;
      } else if (skillWord.includes(keyword) || keyword.includes(skillWord)) {
        score += 0.2; // Partial match
      }
    }
  }

  // Category match
  if (skillCategory === intentCategory) score += 0.3;

  // Skill level boost (higher level = more confidence)
  score += (skillLevel / 5) * 0.1;

  return Math.min(score, 1.0);
}

function roleMatchesIntent(personaRole: string, intentCategory: string): boolean {
  const roleLower = personaRole.toLowerCase();
  const ROLE_CATEGORY_MAP: Record<string, string[]> = {
    coding: ["developer", "engineer", "programmer", "coder", "architect"],
    creative: ["designer", "artist", "creative", "visual", "ux", "ui"],
    research: ["researcher", "analyst", "scientist", "investigator"],
    writing: ["writer", "editor", "copywriter", "content", "communications"],
    planning: ["manager", "planner", "strategist", "coordinator", "lead"],
    reasoning: ["analyst", "mathematician", "logician", "scientist"],
  };

  const roleKeywords = ROLE_CATEGORY_MAP[intentCategory] ?? [];
  return roleKeywords.some(k => roleLower.includes(k));
}
```

### Conversation Context Inference 

When skill matching is ambiguous, Cipher looks at who has been active in the conversation recently:

```ts
function inferFromContext(
  recentMessages: Message[],
  participants: Persona[]
): Persona | null {
  if (recentMessages.length === 0) return null;

  // Find the most recent persona message
  const lastPersonaMessage = [...recentMessages]
    .reverse()
    .find(m => m.sender_type === "persona");

  if (!lastPersonaMessage) return null;

  const persona = participants.find(p => p.id === lastPersonaMessage.sender_id);
  if (!persona) return null;

  // Only use context if the last persona message was within the last 5 messages
  const lastFiveMessages = recentMessages.slice(-5);
  const isRecent = lastFiveMessages.some(m => m.id === lastPersonaMessage.id);

  return isRecent ? persona : null;
}
```

### Routing Note Format 

The routing note is stored in `messages.routing_reason` and displayed when "Show routing notes" is enabled (Part 13).

| Reason | Note Template | Example |
| :---- | :---- | :---- |
| `mention` | "Directed to {name} by user." | "Directed to Sally by user." |
| `skill_match` | "Routed to {name} — best skill match for "{skill}" ({pct}%)." | "Routed to Dev — best skill match for "React components" (82%)." |
| `context` | "Continuing with {name} (active in conversation)." | "Continuing with Sally (active in conversation)." |
| `instance_default` | "Using {name} — primary persona for this workspace." | "Using Sally — primary persona for this workspace." |
| `global_default` | "Using {name} — default participant." | "Using Sally — default participant." |
| `skill_redirect` | "Redirected from {original} to {name} — {reason}." | "Redirected from Sally to Dev — request requires coding skills." |

---

## 14.4 Skill Validation 

After routing selects a persona, Cipher validates that the persona can handle the request by checking boundaries (will\_do / wont\_do / escalation from Part 9).

```ts
interface SkillValidation {
  redirect: boolean;
  redirectTo?: Persona;
  redirectNote?: string;
}

async function validateSkills(
  client: SupabaseClient,
  persona: Persona,
  message: string,
  chatId: string,
  participants: Persona[]
): Promise<SkillValidation> {
  // Load boundaries
  const { data: boundaries } = await client
    .from("persona_boundaries")
    .select("type, description")
    .eq("persona_id", persona.id);

  if (!boundaries || boundaries.length === 0) {
    return { redirect: false };
  }

  const wontDo = boundaries.filter(b => b.type === "wont_do");
  const escalations = boundaries.filter(b => b.type === "escalation");

  const messageKeywords = extractIntentKeywords(message);

  // Check "won't do" boundaries
  for (const boundary of wontDo) {
    const boundaryKeywords = extractIntentKeywords(boundary.description);
    const overlap = boundaryKeywords.filter(k =>
      messageKeywords.some(mk => mk === k || mk.includes(k) || k.includes(mk))
    );

    if (overlap.length >= 2 || (overlap.length >= 1 && boundaryKeywords.length <= 3)) {
      // Try to find a better persona among participants
      const alternative = await findAlternativePersona(
        client, message, participants, persona.id
      );

      if (alternative) {
        return {
          redirect: true,
          redirectTo: alternative,
          redirectNote: `${persona.name} can't handle this (boundary: "${boundary.description}"). Redirected to ${alternative.name}.`,
        };
      }

      // No alternative found — persona responds with disclaimer
      return { redirect: false };
    }
  }

  // Check escalation triggers
  for (const escalation of escalations) {
    const escalationKeywords = extractIntentKeywords(escalation.description);
    const overlap = escalationKeywords.filter(k => messageKeywords.some(mk => mk === k));

    if (overlap.length >= 2) {
      // Don't redirect — persona handles it, but insert escalation system message
      await client.from("messages").insert({
        chat_id: chatId,
        sender_type: "system",
        sender_name: "System",
        content: `⚠️ Escalation: ${escalation.description}`,
        metadata: { escalation: true, persona_id: persona.id },
      });

      return { redirect: false };
    }
  }

  return { redirect: false };
}

async function findAlternativePersona(
  client: SupabaseClient,
  message: string,
  participants: Persona[],
  excludeId: string
): Promise<Persona | null> {
  const candidates = participants.filter(p => p.id !== excludeId);
  if (candidates.length === 0) return null;

  const match = await matchBySkills(client, message, candidates);
  return match && match.confidence >= 0.5 ? match.persona : null;
}
```

---

## 14.5 Model Selection 

Model selection maps the detected intent category to the user's configured model role assignments (Part 13).

```ts
function selectModel(
  modelOverride: string | null,
  persona: Persona,
  intentCategory: string,
  profile: Profile
): string {
  // Priority 1: Explicit user override (from model selector in chat composer)
  if (modelOverride) return modelOverride;

  // Priority 2: Persona-specific model override
  if (persona.settings?.model) return persona.settings.model;

  // Priority 3: Role assignment from profile
  const roleAssignment = profile.model_role_assignments?.find(
    r => r.role === intentCategory
  );
  if (roleAssignment) return roleAssignment.primary_model;

  // Priority 4: Default model for the user's tier
  return getDefaultModelForTier(profile.tier);
}

function getDefaultModelForTier(tier: string): string {
  switch (tier) {
    case "pro":
    case "premium":
    case "plus":
      return "claude-sonnet-4-5-20250929";
    case "free":
    default:
      return "claude-haiku-4-5-20251001";
  }
}
```

### Fallback Chain 

If the primary model fails, Cipher tries the configured fallback before surfacing an error:

```ts
async function callModelWithFallback(
  apiKey: string,
  primaryModelId: string,
  context: ContextWindow,
  profile: Profile,
  intentCategory: string
): Promise<ModelResponse> {
  try {
    return await callModel(apiKey, primaryModelId, context);
  } catch (primaryError) {
    console.error(`Primary model ${primaryModelId} failed:`, primaryError);

    const roleAssignment = profile.model_role_assignments?.find(
      r => r.role === intentCategory
    );
    const fallbackModelId = roleAssignment?.fallback_model;
    if (!fallbackModelId) throw primaryError;

    try {
      return await callModel(apiKey, fallbackModelId, context);
    } catch (fallbackError) {
      console.error(`Fallback model ${fallbackModelId} also failed:`, fallbackError);
      throw fallbackError;
    }
  }
}
```

### API Key Resolution 

```ts
async function resolveApiKey(
  client: SupabaseClient,
  userId: string,
  modelId: string,
  profile: Profile
): Promise<string> {
  // Check for active BYOK key
  const { data: userKeys } = await client
    .from("api_keys")
    .select("id, provider, encrypted_key")
    .eq("user_id", userId)
    .eq("status", "active")
    .order("created_at", { ascending: true });

  if (userKeys && userKeys.length > 0) {
    const provider = inferProvider(modelId);
    const matchingKey = userKeys.find(k => k.provider === provider)
      ?? userKeys.find(k => k.provider === "openrouter");

    if (matchingKey) {
      return await decryptApiKey(matchingKey.encrypted_key);
    }
  }

  return Deno.env.get("OPENROUTER_API_KEY")!;
}

function inferProvider(modelId: string): string {
  if (modelId.startsWith("claude")) return "anthropic";
  if (modelId.startsWith("gpt") || modelId.startsWith("dall-e")) return "openai";
  if (modelId.startsWith("gemini")) return "google";
  if (modelId.startsWith("deepseek")) return "deepseek";
  return "openrouter";
}
```

---

## 14.6 Context Window Assembly 

Context assembly is the most critical step in producing high-quality responses. Cipher builds a structured prompt that includes everything the model needs to respond as the selected persona.

### Token Budget Allocation 

```ts
const MODEL_CONTEXT_LIMITS: Record<string, number> = {
  "claude-opus-4-5-20250301": 200000,
  "claude-sonnet-4-5-20250929": 200000,
  "claude-haiku-4-5-20251001": 200000,
  "gpt-4o": 128000,
  "gpt-4o-mini": 128000,
  "gemini-2.5-pro": 1000000,
  "gemini-2.5-flash": 1000000,
  "deepseek-r1": 128000,
};

const RESPONSE_RESERVE = 4096;

interface TokenBudget {
  system_prompt: number;
  memories: number;
  instruction_rules: number;
  instance_context: number;
  conversation_history: number;
  user_message: number;
  attachment_context: number;
}

function allocateBudget(modelId: string, userMessageTokens: number): TokenBudget {
  const contextLimit = MODEL_CONTEXT_LIMITS[modelId] ?? 128000;
  const available = contextLimit - RESPONSE_RESERVE - userMessageTokens;

  const systemPrompt = Math.min(1500, available * 0.1);
  const memories = Math.min(2000, available * 0.15);
  const instructionRules = Math.min(500, available * 0.03);
  const instanceContext = Math.min(500, available * 0.03);
  const attachmentContext = Math.min(1000, available * 0.05);
  const conversationHistory = available - systemPrompt - memories - instructionRules - instanceContext - attachmentContext;

  return {
    system_prompt: Math.floor(systemPrompt),
    memories: Math.floor(memories),
    instruction_rules: Math.floor(instructionRules),
    instance_context: Math.floor(instanceContext),
    conversation_history: Math.max(0, Math.floor(conversationHistory)),
    user_message: userMessageTokens,
    attachment_context: Math.floor(attachmentContext),
  };
}
```

### Assembly Function 

```ts
async function assembleContext(
  client: SupabaseClient,
  params: {
    persona: Persona;
    memories: boolean;
    recentMessages: Message[];
    instanceData: Instance | null;
    userMessage: string;
    attachments: Attachment[];
    profile: Profile;
    modelId: string;
  }
): Promise<ContextWindow> {
  const userMessageTokens = estimateTokens(params.userMessage);
  const budget = allocateBudget(params.modelId, userMessageTokens);

  // Build all components in parallel
  const [
    personaIdentity,
    personaBoundaries,
    memories,
    instructionRules,
    instanceContext,
    attachmentContext,
  ] = await Promise.all([
    buildPersonaIdentity(params.persona, budget.system_prompt),
    buildPersonaBoundaries(client, params.persona.id),
    params.memories
      ? retrieveMemories(client, params.persona.id, params.userMessage, budget.memories)
      : "",
    loadInstructionRules(client, params.profile.id, params.instanceData, budget.instruction_rules),
    buildInstanceContext(params.instanceData, budget.instance_context),
    buildAttachmentContext(params.attachments, budget.attachment_context),
  ]);

  // Assemble system prompt
  const systemParts: string[] = [personaIdentity];
  if (personaBoundaries) systemParts.push(personaBoundaries);
  if (memories) systemParts.push(`\n<relevant_memories>\n${memories}\n</relevant_memories>`);
  if (instructionRules) systemParts.push(`\n<user_instructions>\n${instructionRules}\n</user_instructions>`);
  if (instanceContext) systemParts.push(`\n<workspace_context>\n${instanceContext}\n</workspace_context>`);

  const systemPrompt = systemParts.join("\n\n");

  // Build conversation history
  const history = buildConversationHistory(params.recentMessages, budget.conversation_history);

  // Final user message with attachment context
  const finalUserContent = attachmentContext
    ? `${params.userMessage}\n\n[Attached files: ${attachmentContext}]`
    : params.userMessage;

  const messages = [
    { role: "system" as const, content: systemPrompt },
    ...history,
    { role: "user" as const, content: finalUserContent },
  ];

  return {
    system_prompt: systemPrompt,
    messages,
    total_tokens: estimateTokens(systemPrompt)
      + history.reduce((sum, m) => sum + estimateTokens(m.content), 0)
      + userMessageTokens,
  };
}
```

### Persona Identity Builder 

```ts
function buildPersonaIdentity(persona: Persona, tokenBudget: number): string {
  const identity = `You are ${persona.name}, ${persona.role}.

${persona.purpose ? `Purpose: ${persona.purpose}` : ""}

${persona.personality ? `Personality: ${persona.personality}` : ""}

${persona.voice ? `Communication style: ${persona.voice}` : ""}

You are a persistent AI persona in the aiConnected platform. You have memory of past conversations, bounded skills, and explicit limits. You should:
- Stay in character as ${persona.name} at all times
- Acknowledge when a request is outside your skill set rather than attempting it poorly
- Reference past memories and decisions when relevant
- Be honest about your confidence level
- Suggest involving another persona when appropriate

Current mood: ${persona.mood || "Neutral"}
Current status: ${persona.status}`;

  return truncateToTokenBudget(identity, tokenBudget);
}
```

### Persona Boundaries Builder 

```ts
async function buildPersonaBoundaries(
  client: SupabaseClient,
  personaId: string
): Promise<string> {
  const { data: boundaries } = await client
    .from("persona_boundaries")
    .select("type, description")
    .eq("persona_id", personaId);

  if (!boundaries || boundaries.length === 0) return "";

  const willDo = boundaries.filter(b => b.type === "will_do").map(b => `- ${b.description}`);
  const wontDo = boundaries.filter(b => b.type === "wont_do").map(b => `- ${b.description}`);
  const escalations = boundaries.filter(b => b.type === "escalation").map(b => `- ${b.description}`);

  const parts: string[] = [];

  if (willDo.length > 0) {
    parts.push(`<capabilities>\nYou are able to and should do:\n${willDo.join("\n")}\n</capabilities>`);
  }
  if (wontDo.length > 0) {
    parts.push(`<boundaries>\nYou should NOT do the following. If asked, politely decline and suggest who might help:\n${wontDo.join("\n")}\n</boundaries>`);
  }
  if (escalations.length > 0) {
    parts.push(`<escalation_rules>\nEscalate to the user (flag for their attention) when:\n${escalations.join("\n")}\n</escalation_rules>`);
  }

  return parts.join("\n\n");
}
```

### Memory Retrieval 

Retrieves relevant memories from `persona_memories` using pgvector semantic search combined with recency and confidence weighting.

```ts
async function retrieveMemories(
  client: SupabaseClient,
  personaId: string,
  userMessage: string,
  tokenBudget: number
): Promise<string> {
  const embedding = await generateEmbedding(userMessage);

  const { data: memories } = await client.rpc("search_persona_memories", {
    p_persona_id: personaId,
    p_embedding: embedding,
    p_match_threshold: 0.5,
    p_match_count: 20,
  });

  if (!memories || memories.length === 0) return "";

  // Re-rank by composite score
  const ranked = memories.map((m: any) => ({
    ...m,
    composite_score: computeMemoryScore(m.similarity, m.created_at, m.confidence),
  }));
  ranked.sort((a: any, b: any) => b.composite_score - a.composite_score);

  // Fill up to token budget
  let totalTokens = 0;
  const selected: string[] = [];

  for (const memory of ranked) {
    const memoryText = `[${memory.type.charAt(0).toUpperCase() + memory.type.slice(1)}] ${memory.content} (confidence: ${memory.confidence})`;
    const tokens = estimateTokens(memoryText);
    if (totalTokens + tokens > tokenBudget) break;
    selected.push(memoryText);
    totalTokens += tokens;
  }

  return selected.join("\n");
}

function computeMemoryScore(
  similarity: number,
  createdAt: string,
  confidence: string
): number {
  const ageHours = (Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60);
  const recencyBoost = Math.max(0, 1 - ageHours / (24 * 90));
  const confidenceMultiplier = confidence === "strong" ? 1.0 : confidence === "medium" ? 0.8 : 0.5;
  return similarity * 0.6 + recencyBoost * 0.2 + confidenceMultiplier * 0.2;
}
```

**Semantic search RPC:**

```sql
CREATE OR REPLACE FUNCTION search_persona_memories(
  p_persona_id UUID,
  p_embedding vector(1536),
  p_match_threshold FLOAT DEFAULT 0.5,
  p_match_count INT DEFAULT 20
)
RETURNS TABLE (
  id UUID,
  type TEXT,
  content TEXT,
  confidence TEXT,
  tags TEXT[],
  created_at TIMESTAMPTZ,
  similarity FLOAT
)
LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY
  SELECT
    pm.id,
    pm.type,
    pm.content,
    pm.confidence,
    pm.tags,
    pm.created_at,
    1 - (pm.embedding <=> p_embedding) AS similarity
  FROM persona_memories pm
  WHERE pm.persona_id = p_persona_id
    AND pm.active = true
    AND pm.embedding IS NOT NULL
    AND 1 - (pm.embedding <=> p_embedding) > p_match_threshold
  ORDER BY pm.embedding <=> p_embedding
  LIMIT p_match_count;
END;
$$;
```

### Instruction Rules Loading 

```ts
async function loadInstructionRules(
  client: SupabaseClient,
  userId: string,
  instanceData: Instance | null,
  tokenBudget: number
): Promise<string> {
  const { data: allRules } = await client
    .from("instruction_memory")
    .select("*")
    .eq("user_id", userId)
    .eq("active", true)
    .order("created_at", { ascending: false });

  if (!allRules || allRules.length === 0) return "";

  const applicableRules = allRules.filter(rule => {
    if (rule.scope === "global") return true;
    if (rule.scope === "type" && instanceData) return rule.scope_target_name === instanceData.type;
    if (rule.scope === "instance" && instanceData) return rule.scope_target_id === instanceData.id;
    return false;
  });

  if (applicableRules.length === 0) return "";

  let totalTokens = 0;
  const ruleTexts: string[] = [];
  for (const rule of applicableRules) {
    const ruleText = `- ${rule.rule}`;
    const tokens = estimateTokens(ruleText);
    if (totalTokens + tokens > tokenBudget) break;
    ruleTexts.push(ruleText);
    totalTokens += tokens;
  }

  return `The user has established these behavioral rules:\n${ruleTexts.join("\n")}`;
}
```

### Instance Context & Conversation History Builders 

```ts
function buildInstanceContext(instanceData: Instance | null, tokenBudget: number): string {
  if (!instanceData) return "";
  const parts: string[] = [`Current workspace: "${instanceData.name}" (${instanceData.type})`];
  if (instanceData.description) parts.push(`Description: ${instanceData.description}`);
  if (instanceData.settings && Object.keys(instanceData.settings).length > 0) {
    const summary = Object.entries(instanceData.settings).map(([k, v]) => `${k}: ${v}`).join(", ");
    parts.push(`Workspace settings: ${summary}`);
  }
  return truncateToTokenBudget(parts.join("\n"), tokenBudget);
}

function buildConversationHistory(
  recentMessages: Message[],
  tokenBudget: number
): { role: "user" | "assistant"; content: string }[] {
  const history: { role: "user" | "assistant"; content: string }[] = [];
  let totalTokens = 0;

  for (let i = recentMessages.length - 1; i >= 0; i--) {
    const msg = recentMessages[i];
    if (msg.sender_type === "system" || msg.sender_type === "cipher") continue;

    const role = msg.sender_type === "user" ? "user" as const : "assistant" as const;
    const content = msg.sender_type === "persona"
      ? `[${msg.sender_name}]: ${msg.content}`
      : msg.content;

    const tokens = estimateTokens(content);
    if (totalTokens + tokens > tokenBudget) break;
    history.unshift({ role, content });
    totalTokens += tokens;
  }

  return history;
}
```

### Token Estimation 

```ts
function estimateTokens(text: string): number {
  return Math.ceil(text.length / 3.5);
}

function truncateToTokenBudget(text: string, tokenBudget: number): string {
  const estimatedTokens = estimateTokens(text);
  if (estimatedTokens <= tokenBudget) return text;
  const charBudget = Math.floor(tokenBudget * 3.5);
  return text.slice(0, charBudget) + "...";
}

async function generateEmbedding(text: string): Promise<number[]> {
  const client = new OpenAI({
    apiKey: Deno.env.get("OPENROUTER_API_KEY")!,
    baseURL: "https://openrouter.ai/api/v1",
  });
  const response = await client.embeddings.create({
    model: "text-embedding-3-small",
    input: text,
  });
  return response.data[0].embedding;
}
```

---

## 14.7 Streaming Response 

Cipher streams the AI model's response back to the client using Server-Sent Events (SSE).

```ts
async function streamResponse(
  apiKey: string,
  modelId: string,
  context: ContextWindow,
  metadata: {
    serviceClient: SupabaseClient;
    chatId: string;
    personaId: string;
    personaName: string;
    routingReason: string;
    routingNote: string;
    userId: string;
    userMessageId: string;
  }
): Promise<Response> {
  const client = new OpenAI({
    apiKey,
    baseURL: "https://openrouter.ai/api/v1",
  });

  const startTime = Date.now();
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      let fullResponse = "";
      let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };

      try {
        const completion = await client.chat.completions.create({
          model: modelId,
          messages: context.messages,
          stream: true,
          max_tokens: 4096,
        });

        for await (const chunk of completion) {
          const content = chunk.choices[0]?.delta?.content;
          if (content) {
            fullResponse += content;
            controller.enqueue(
              encoder.encode(`data: ${JSON.stringify({ type: "token", content })}\n\n`)
            );
          }
          if (chunk.usage) usage = chunk.usage;
        }

        const latencyMs = Date.now() - startTime;

        // Save complete response
        const { data: personaMessage } = await metadata.serviceClient
          .from("messages")
          .insert({
            chat_id: metadata.chatId,
            sender_type: "persona",
            sender_id: metadata.personaId,
            sender_name: metadata.personaName,
            content: fullResponse,
            content_blocks: detectContentBlocks(fullResponse),
            model_used: modelId,
            routing_reason: metadata.routingReason,
            token_count: usage.total_tokens || estimateTokens(fullResponse),
            latency_ms: latencyMs,
            has_link: containsUrl(fullResponse),
            has_media: false,
            metadata: {},
          })
          .select()
          .single();

        // Send completion event
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({
            type: "done",
            message_id: personaMessage?.id,
            model_used: modelId,
            routing_reason: metadata.routingReason,
            routing_note: metadata.routingNote,
            token_count: usage.total_tokens,
            latency_ms: latencyMs,
          })}\n\n`)
        );

        // Dispatch async post-response tasks (fire-and-forget)
        dispatchPostResponseTasks(metadata.serviceClient, {
          chatId: metadata.chatId,
          userId: metadata.userId,
          personaId: metadata.personaId,
          personaName: metadata.personaName,
          userMessageId: metadata.userMessageId,
          personaMessageId: personaMessage?.id,
          userMessage: context.messages[context.messages.length - 1].content,
          personaResponse: fullResponse,
          modelId,
        });

      } catch (error: any) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({
            type: "error",
            message: error.message || "An error occurred",
            code: error.status || 500,
          })}\n\n`)
        );
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Connection": "keep-alive",
      "Access-Control-Allow-Origin": "*",
    },
  });
}
```

### Content Block Detection 

````ts
function detectContentBlocks(response: string): any[] {
  const blocks: any[] = [];

  // Code blocks
  const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
  let match;
  while ((match = codeBlockRegex.exec(response)) !== null) {
    blocks.push({
      type: "code_block",
      language: match[1] || "text",
      code: match[2].trim(),
    });
  }

  // Markdown tables
  const tableRegex = /\|(.+)\|\n\|[-|:\s]+\|\n((?:\|.+\|\n?)+)/g;
  while ((match = tableRegex.exec(response)) !== null) {
    const headers = match[1].split("|").map(h => h.trim()).filter(Boolean);
    const rows = match[2].trim().split("\n").map(row =>
      row.split("|").map(cell => cell.trim()).filter(Boolean)
    );
    blocks.push({ type: "comparison_table", headers, rows });
  }

  // Standalone URLs
  const urlRegex = /(?:^|\n)(https?:\/\/[^\s)]+)(?:\n|$)/g;
  while ((match = urlRegex.exec(response)) !== null) {
    blocks.push({ type: "link_preview", url: match[1], title: "", description: "" });
  }

  return blocks.length > 0 ? blocks : [];
}
````

---

## 14.8 cipher-memory: Post-Response Processing 

After every AI response, Cipher runs asynchronous post-response tasks. These do not block the user.

### Dispatch 

```ts
async function dispatchPostResponseTasks(
  client: SupabaseClient,
  params: {
    chatId: string;
    userId: string;
    personaId: string;
    personaName: string;
    userMessageId: string;
    personaMessageId: string;
    userMessage: string;
    personaResponse: string;
    modelId: string;
  }
) {
  await Promise.allSettled([
    extractMemories(client, params),
    detectInstructionPatterns(client, params),
    checkAutoRename(client, params),
    checkSuggestedMove(client, params),
    logActivity(client, params),
    updateChatTimestamp(client, params.chatId),
  ]);
}
```

### Memory Extraction 

Cipher calls a lightweight model to analyze the exchange and identify extractable memories:

````ts
async function extractMemories(
  client: SupabaseClient,
  params: {
    chatId: string;
    userId: string;
    personaId: string;
    userMessage: string;
    personaResponse: string;
    userMessageId: string;
    personaMessageId: string;
  }
) {
  const extractionPrompt = `Analyze this conversation exchange and extract any new knowledge worth remembering.

User said: "${params.userMessage}"

Assistant responded: "${params.personaResponse}"

For each piece of knowledge, classify it as one of:
- decision: A choice that was made
- fact: An objective piece of information
- preference: A user preference or opinion
- skill: Something the persona demonstrated ability to do

Respond ONLY with a JSON array. Each item:
{
  "type": "decision" | "fact" | "preference" | "skill",
  "content": "A concise statement of the knowledge",
  "confidence": "strong" | "medium" | "weak"
}

If there is nothing worth extracting, respond with an empty array: []
Do NOT extract trivial information (greetings, acknowledgments, small talk).`;

  const extractionClient = new OpenAI({
    apiKey: Deno.env.get("OPENROUTER_API_KEY")!,
    baseURL: "https://openrouter.ai/api/v1",
  });

  const response = await extractionClient.chat.completions.create({
    model: "claude-haiku-4-5-20251001",
    messages: [{ role: "user", content: extractionPrompt }],
    max_tokens: 1000,
  });

  const responseText = response.choices[0]?.message?.content ?? "[]";

  let memories: { type: string; content: string; confidence: string }[];
  try {
    const cleaned = responseText.replace(/```json\n?|```/g, "").trim();
    memories = JSON.parse(cleaned);
  } catch {
    return;
  }

  if (!Array.isArray(memories) || memories.length === 0) return;

  let savedCount = 0;

  for (const memory of memories) {
    if (!memory.type || !memory.content) continue;

    const embedding = await generateEmbedding(memory.content);
    const conflict = await checkMemoryConflict(client, params.personaId, embedding, memory.content);

    if (conflict.isDuplicate) continue;

    await client.from("persona_memories").insert({
      persona_id: params.personaId,
      type: memory.type,
      content: memory.content,
      source_chat_id: params.chatId,
      source_message_id: params.personaMessageId,
      confidence: memory.confidence,
      tags: conflict.isConflict ? ["conflict"] : [],
      active: true,
      embedding,
    });

    savedCount++;
  }

  if (savedCount > 0) {
    await client
      .from("messages")
      .update({ metadata: { memories_saved: savedCount } })
      .eq("id", params.personaMessageId);
  }
}
````

### Memory Conflict Detection 

```ts
async function checkMemoryConflict(
  client: SupabaseClient,
  personaId: string,
  embedding: number[],
  content: string
): Promise<{ isDuplicate: boolean; isConflict: boolean }> {
  const { data: similar } = await client.rpc("search_persona_memories", {
    p_persona_id: personaId,
    p_embedding: embedding,
    p_match_threshold: 0.85,
    p_match_count: 5,
  });

  if (!similar || similar.length === 0) return { isDuplicate: false, isConflict: false };

  // Very high similarity = duplicate
  if (similar.find((m: any) => m.similarity > 0.95)) return { isDuplicate: true, isConflict: false };

  // High similarity + negation keywords = potential conflict
  const hasNegation = content.toLowerCase().match(
    /\b(not|no longer|never|stopped|changed|instead|rather than|replaced|switched)\b/
  );

  if (hasNegation) {
    await client
      .from("persona_memories")
      .update({ tags: [...(similar[0].tags ?? []), "conflict"] })
      .eq("id", similar[0].id);
    return { isDuplicate: false, isConflict: true };
  }

  return { isDuplicate: false, isConflict: false };
}
```

### Instruction Pattern Detection 

````ts
async function detectInstructionPatterns(
  client: SupabaseClient,
  params: { chatId: string; userId: string; userMessage: string }
) {
  const explicitPatterns = [
    /\b(always|never|every time|from now on|going forward|remember to|don't forget to|make sure to)\b/i,
  ];

  const isExplicitInstruction = explicitPatterns.some(p => p.test(params.userMessage));
  if (!isExplicitInstruction) return;

  const extractionPrompt = `The user gave a behavioral instruction. Extract the core rule.

User message: "${params.userMessage}"

Respond with ONLY a JSON object:
{
  "rule": "concise rule statement",
  "scope": "global" or "instance"
}

If this is NOT actually a behavioral instruction, respond with: null`;

  const extractionClient = new OpenAI({
    apiKey: Deno.env.get("OPENROUTER_API_KEY")!,
    baseURL: "https://openrouter.ai/api/v1",
  });

  const response = await extractionClient.chat.completions.create({
    model: "claude-haiku-4-5-20251001",
    messages: [{ role: "user", content: extractionPrompt }],
    max_tokens: 200,
  });

  const text = response.choices[0]?.message?.content ?? "null";

  try {
    const cleaned = text.replace(/```json\n?|```/g, "").trim();
    const result = JSON.parse(cleaned);
    if (!result || !result.rule) return;

    // Deduplicate
    const { data: existing } = await client
      .from("instruction_memory")
      .select("id, rule")
      .eq("user_id", params.userId)
      .eq("active", true);

    const isDuplicate = (existing ?? []).some(
      e => e.rule.toLowerCase() === result.rule.toLowerCase()
    );
    if (isDuplicate) return;

    // Determine scope
    const { data: chat } = await client
      .from("chats")
      .select("instance_id, instances(id, name, type)")
      .eq("id", params.chatId)
      .single();

    const scope = result.scope === "instance" && chat?.instance_id ? "instance" : "global";

    await client.from("instruction_memory").insert({
      user_id: params.userId,
      rule: result.rule,
      scope,
      scope_target_id: scope === "instance" ? chat?.instance_id : null,
      scope_target_name: scope === "instance" ? chat?.instances?.name ?? "" : "",
      source_chat_id: params.chatId,
      source_description: "",
      active: true,
    });
  } catch {
    // Parse failed — skip
  }
}
````

### Auto-Rename & Suggested Move 

```ts
async function checkAutoRename(
  client: SupabaseClient,
  params: { chatId: string; userId: string }
) {
  const { data: chat } = await client
    .from("chats")
    .select("id, title, auto_title")
    .eq("id", params.chatId)
    .single();

  if (!chat || chat.auto_title) return;

  const { data: profile } = await client
    .from("profiles")
    .select("preferences")
    .eq("id", params.userId)
    .single();

  if (!profile?.preferences?.auto_rename) return;

  const { count } = await client
    .from("messages")
    .select("id", { count: "exact", head: true })
    .eq("chat_id", params.chatId);

  if ((count ?? 0) < 6) return;

  const { data: messages } = await client
    .from("messages")
    .select("sender_name, content")
    .eq("chat_id", params.chatId)
    .order("created_at", { ascending: true })
    .limit(10);

  const summary = (messages ?? [])
    .map(m => `${m.sender_name}: ${m.content.slice(0, 200)}`)
    .join("\n");

  const renameClient = new OpenAI({
    apiKey: Deno.env.get("OPENROUTER_API_KEY")!,
    baseURL: "https://openrouter.ai/api/v1",
  });

  const response = await renameClient.chat.completions.create({
    model: "claude-haiku-4-5-20251001",
    messages: [{
      role: "user",
      content: `Suggest a short title (5-8 words) for this conversation:\n${summary}\nRespond with only the title.`,
    }],
    max_tokens: 50,
  });

  const suggestedTitle = response.choices[0]?.message?.content?.trim();
  if (suggestedTitle) {
    await client.from("chats").update({ auto_title: suggestedTitle }).eq("id", params.chatId);
  }
}

async function checkSuggestedMove(
  client: SupabaseClient,
  params: { chatId: string; userId: string }
) {
  const { data: chat } = await client
    .from("chats")
    .select("id, instance_id, metadata")
    .eq("id", params.chatId)
    .single();

  if (!chat || chat.instance_id || chat.metadata?.suggested_instance_id || chat.metadata?.move_dismissed) return;

  const { data: profile } = await client
    .from("profiles")
    .select("preferences")
    .eq("id", params.userId)
    .single();

  if (!profile?.preferences?.suggest_moves) return;

  const { count } = await client
    .from("messages")
    .select("id", { count: "exact", head: true })
    .eq("chat_id", params.chatId)
    .eq("sender_type", "user");

  if ((count ?? 0) < 5) return;

  const { data: instances } = await client
    .from("instances")
    .select("id, name, description")
    .eq("user_id", params.userId)
    .is("archived_at", null);

  if (!instances || instances.length === 0) return;

  const { data: messages } = await client
    .from("messages")
    .select("content")
    .eq("chat_id", params.chatId)
    .eq("sender_type", "user")
    .limit(10);

  const conversationText = (messages ?? []).map(m => m.content).join(" ");
  const conversationEmbedding = await generateEmbedding(conversationText);

  let bestMatch: { id: string; name: string; similarity: number } | null = null;

  for (const instance of instances) {
    const instanceText = `${instance.name} ${instance.description ?? ""}`;
    const instanceEmbedding = await generateEmbedding(instanceText);
    const similarity = cosineSimilarity(conversationEmbedding, instanceEmbedding);

    if (similarity > 0.7 && (!bestMatch || similarity > bestMatch.similarity)) {
      bestMatch = { id: instance.id, name: instance.name, similarity };
    }
  }

  if (bestMatch) {
    await client.from("chats").update({
      metadata: {
        ...chat.metadata,
        suggested_instance_id: bestMatch.id,
        suggested_instance_name: bestMatch.name,
      },
    }).eq("id", params.chatId);
  }
}

function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0, normA = 0, normB = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }
  return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
```

### Activity Logging 

```ts
async function logActivity(
  client: SupabaseClient,
  params: { userId: string; personaId: string; personaName: string; chatId: string; personaResponse: string }
) {
  await client.from("activity_log").insert({
    user_id: params.userId,
    actor_type: "persona",
    actor_id: params.personaId,
    actor_name: params.personaName,
    action: "responded in conversation",
    entity_type: "chat",
    entity_id: params.chatId,
  });
}

async function updateChatTimestamp(client: SupabaseClient, chatId: string) {
  await client.from("chats").update({ updated_at: new Date().toISOString() }).eq("id", chatId);
}
```

---

## 14.9 cipher-cleanup: Background Maintenance 

The cleanup function runs on the user's configured schedule. It handles housekeeping that keeps the platform organized.

### Scheduling 

```sql
SELECT cron.schedule(
  'cipher-cleanup-dispatch',
  '0 * * * *',  -- Every hour; checks which users are due
  $$
  SELECT net.http_post(
    url := current_setting('app.supabase_url') || '/functions/v1/cipher-cleanup',
    headers := jsonb_build_object(
      'Authorization', 'Bearer ' || current_setting('app.service_role_key'),
      'Content-Type', 'application/json'
    ),
    body := jsonb_build_object('batch', true)
  );
  $$
);
```

### Implementation 

```ts
// supabase/functions/cipher-cleanup/index.ts

serve(async (req: Request) => {
  const serviceClient = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );

  const now = new Date();
  const { data: users } = await serviceClient
    .from("profiles")
    .select("id, preferences")
    .eq("preferences->>scheduled_cleanup", "true");

  if (!users) return new Response("OK");

  for (const user of users) {
    const frequency = user.preferences?.cleanup_frequency ?? "daily";
    if (!isCleanupDue(frequency, now)) continue;

    await Promise.allSettled([
      flagStaleMemories(serviceClient, user.id),
      decayWeakMemories(serviceClient, user.id),
      expireTemporarySkills(serviceClient, user.id),
      purgeSoftDeletedChats(serviceClient, user.id),
      flagOldUnnamedChats(serviceClient, user.id),
    ]);
  }

  return new Response("OK");
});

function isCleanupDue(frequency: string, now: Date): boolean {
  switch (frequency) {
    case "hourly": return true;
    case "daily": return now.getUTCHours() === 3;
    case "weekly": return now.getUTCDay() === 0 && now.getUTCHours() === 3;
    default: return false;
  }
}
```

### Task 1: Stale Memory Flagging 

```ts
async function flagStaleMemories(client: SupabaseClient, userId: string) {
  const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();

  const { data: stale } = await client
    .from("persona_memories")
    .select("id, tags")
    .in("persona_id", client.from("personas").select("id").eq("user_id", userId))
    .eq("active", true)
    .lt("updated_at", ninetyDaysAgo)
    .not("tags", "cs", "{stale}");

  for (const memory of stale ?? []) {
    await client
      .from("persona_memories")
      .update({ tags: [...(memory.tags ?? []), "stale"] })
      .eq("id", memory.id);
  }
}
```

### Task 2: Weak Memory Decay 

```ts
async function decayWeakMemories(client: SupabaseClient, userId: string) {
  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();

  await client
    .from("persona_memories")
    .update({ active: false })
    .in("persona_id", client.from("personas").select("id").eq("user_id", userId))
    .eq("confidence", "weak")
    .eq("active", true)
    .lt("updated_at", thirtyDaysAgo);
}
```

### Task 3: Temporary Skill Expiration 

```ts
async function expireTemporarySkills(client: SupabaseClient, userId: string) {
  await client
    .from("persona_skills")
    .delete()
    .in("persona_id", client.from("personas").select("id").eq("user_id", userId))
    .eq("temporary", true)
    .lt("expires_at", new Date().toISOString());
}
```

### Task 4: Soft-Delete Purge 

```ts
async function purgeSoftDeletedChats(client: SupabaseClient, userId: string) {
  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
  await client.from("chats").delete()
    .eq("user_id", userId)
    .not("deleted_at", "is", null)
    .lt("deleted_at", thirtyDaysAgo);
}
```

### Task 5: Old Unnamed Chat Flagging 

```ts
async function flagOldUnnamedChats(client: SupabaseClient, userId: string) {
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();

  const { data: unnamed } = await client.from("chats")
    .select("id")
    .eq("user_id", userId)
    .eq("title", "New Chat")
    .is("auto_title", null)
    .is("deleted_at", null)
    .lt("created_at", sevenDaysAgo)
    .limit(10);

  for (const chat of unnamed ?? []) {
    const { data: messages } = await client.from("messages")
      .select("sender_name, content")
      .eq("chat_id", chat.id)
      .order("created_at", { ascending: true })
      .limit(6);

    if (!messages || messages.length < 2) continue;

    const summary = messages.map(m => `${m.sender_name}: ${m.content.slice(0, 150)}`).join("\n");

    const renameClient = new OpenAI({
      apiKey: Deno.env.get("OPENROUTER_API_KEY")!,
      baseURL: "https://openrouter.ai/api/v1",
    });

    const response = await renameClient.chat.completions.create({
      model: "claude-haiku-4-5-20251001",
      messages: [{ role: "user", content: `Suggest a short title (5-8 words) for this conversation:\n${summary}\nRespond with only the title.` }],
      max_tokens: 30,
    });

    const title = response.choices[0]?.message?.content?.trim();
    if (title) {
      await client.from("chats").update({ auto_title: title }).eq("id", chat.id);
    }
  }
}
```

---

## 14.10 cipher-health: Daily Health Monitoring 

Runs daily. Computes metrics for each persona, detects drift, identifies conflicts, updates mood.

### Scheduling 

```sql
SELECT cron.schedule(
  'cipher-health-daily',
  '0 3 * * *',
  $$
  SELECT net.http_post(
    url := current_setting('app.supabase_url') || '/functions/v1/cipher-health',
    headers := jsonb_build_object(
      'Authorization', 'Bearer ' || current_setting('app.service_role_key'),
      'Content-Type', 'application/json'
    ),
    body := '{}'::jsonb
  );
  $$
);
```

### Implementation 

```ts
serve(async (req: Request) => {
  const serviceClient = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );

  const { data: personas } = await serviceClient
    .from("personas")
    .select("id, user_id, name, status")
    .is("archived_at", null)
    .in("status", ["active", "idle"]);

  for (const persona of personas ?? []) {
    await computePersonaHealth(serviceClient, persona);
  }

  return new Response("OK");
});

async function computePersonaHealth(
  client: SupabaseClient,
  persona: { id: string; user_id: string; name: string }
) {
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();

  const [memoryStats, activityStats, conflictCount] = await Promise.all([
    getMemoryStability(client, persona.id),
    getActivityStats(client, persona.id, sevenDaysAgo),
    getConflictCount(client, persona.id),
  ]);

  const driftLevel = computeDrift(memoryStats.stability, activityStats.messageCount, conflictCount);
  const mood = computeMood(activityStats.messageCount, driftLevel);

  await client.from("persona_health_snapshots").insert({
    persona_id: persona.id,
    memory_stability: memoryStats.stability,
    skill_stability: 100, // Placeholder — refined in v2
    mood,
    drift_level: driftLevel,
  });

  await client.from("personas").update({ mood }).eq("id", persona.id);
}
```

### Memory Stability RPC 

```sql
CREATE OR REPLACE FUNCTION persona_memory_stability(p_persona_id UUID)
RETURNS TABLE (stability NUMERIC, total_active BIGINT, conflicted BIGINT)
LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY
  SELECT
    CASE
      WHEN COUNT(*) FILTER (WHERE active = true) = 0 THEN 100.0
      ELSE ROUND(
        (1.0 - (
          COUNT(*) FILTER (WHERE active = true AND tags @> ARRAY['conflict'])::NUMERIC
          / NULLIF(COUNT(*) FILTER (WHERE active = true), 0)
        )) * 100, 2
      )
    END AS stability,
    COUNT(*) FILTER (WHERE active = true) AS total_active,
    COUNT(*) FILTER (WHERE active = true AND tags @> ARRAY['conflict']) AS conflicted
  FROM persona_memories
  WHERE persona_id = p_persona_id;
END;
$$;
```

### Drift & Mood Computation 

```ts
function computeDrift(
  memoryStability: number,
  messageCount: number,
  conflictCount: number
): "low" | "medium" | "high" {
  let score = 0;
  if (conflictCount > 5) score += 3;
  else if (conflictCount > 2) score += 2;
  else if (conflictCount > 0) score += 1;
  if (memoryStability < 70) score += 2;
  else if (memoryStability < 85) score += 1;
  if (messageCount === 0) score += 1;

  if (score >= 4) return "high";
  if (score >= 2) return "medium";
  return "low";
}

function computeMood(messageCount: number, driftLevel: string): string {
  if (messageCount > 20 && driftLevel === "low") return "Energized";
  if (messageCount > 10 && driftLevel === "low") return "Focused";
  if (messageCount > 5 && driftLevel === "low") return "Engaged";
  if (messageCount > 5 && driftLevel === "medium") return "Contemplative";
  if (messageCount > 5 && driftLevel === "high") return "Conflicted";
  if (messageCount > 0 && messageCount <= 2) return "Resting";
  if (messageCount === 0) return "Idle";
  return "Neutral";
}

async function getMemoryStability(client: SupabaseClient, personaId: string) {
  const { data } = await client.rpc("persona_memory_stability", { p_persona_id: personaId });
  return data?.[0] ?? { stability: 100, total_active: 0, conflicted: 0 };
}

async function getActivityStats(client: SupabaseClient, personaId: string, since: string) {
  const { data } = await client
    .from("messages")
    .select("chat_id, latency_ms")
    .eq("sender_id", personaId)
    .eq("sender_type", "persona")
    .gte("created_at", since);

  return { messageCount: data?.length ?? 0, chatCount: new Set(data?.map(m => m.chat_id)).size };
}

async function getConflictCount(client: SupabaseClient, personaId: string): Promise<number> {
  const { count } = await client
    .from("persona_memories")
    .select("id", { count: "exact", head: true })
    .eq("persona_id", personaId)
    .eq("active", true)
    .contains("tags", ["conflict"]);
  return count ?? 0;
}
```

---

## 14.11 Multi-Persona Conversation Coordination 

When multiple personas participate in the same chat, Cipher manages turn-taking and handoffs.

### Turn-Taking Rules 

1. **User directs a specific persona:** That persona responds. No other persona speaks unless invited.  
2. **User sends a general message:** Cipher's routing algorithm picks the best persona. Other personas do not respond unprompted.  
3. **Persona @mentions another persona:** If a persona's response explicitly references another participant in a handoff context, Cipher queues a follow-up from the mentioned persona.  
4. **Team execution mode:** In team runs (Part 10), Cipher coordinates sequential or parallel persona responses according to the DAG plan.

### Handoff Detection 

```ts
function detectHandoff(response: string, participants: Persona[]): Persona | null {
  const handoffPatterns = [
    /(?:I'll (?:let|have|ask)|(?:handing|passing) (?:this |it )?(?:off |over )?to|(?:this|it) (?:is|might be) (?:better|more) for|(?:over to you),?\s*)(\w+)/i,
    /(\w+)(?:,? (?:can you|could you|would you) (?:take|handle|look at|help with))/i,
  ];

  for (const pattern of handoffPatterns) {
    const match = pattern.exec(response);
    if (match) {
      const name = match[1].toLowerCase();
      const persona = participants.find(
        p => p.name.toLowerCase() === name || p.name.toLowerCase().startsWith(name)
      );
      if (persona) return persona;
    }
  }

  return null;
}
```

When detected, Cipher inserts a system message noting the handoff and triggers a follow-up response cycle with the target persona.

---

## 14.12 Error Handling & Resilience 

### Error Classification 

```ts
function classifyError(error: any): { userMessage: string; retryable: boolean; action: string } {
  if (error.status === 429) return { userMessage: "The AI model is temporarily busy. Retrying...", retryable: true, action: "retry_with_delay" };
  if (error.status === 401 || error.status === 403) return { userMessage: "Your API key was rejected. Please check Settings.", retryable: false, action: "invalidate_key" };
  if (error.status === 400 && error.message?.includes("context_length")) return { userMessage: "Conversation too long. Older context was trimmed.", retryable: true, action: "truncate_and_retry" };
  if (error.status >= 500) return { userMessage: "The AI service encountered an error. Please try again.", retryable: true, action: "try_fallback_model" };
  return { userMessage: "Something went wrong. Please try again.", retryable: true, action: "retry" };
}
```

### Retry Configuration 

| Scenario | Max Retries | Initial Delay | Backoff |
| :---- | :---- | :---- | :---- |
| Model API call (primary) | 1 | 2,000ms | 2x |
| Model API call (fallback) | 1 | 1,000ms | 1x |
| Embedding generation | 2 | 1,000ms | 2x |
| Memory extraction | 1 | 500ms | 1x |
| Auto-rename | 0 | — | — |

---

## 14.13 Security Considerations 

### API Key Isolation 

User BYOK keys are decrypted only inside Edge Functions. The decryption function:

```ts
async function decryptApiKey(encryptedKey: string): Promise<string> {
  const encryptionKey = Deno.env.get("API_KEY_ENCRYPTION_KEY")!;
  const [ivBase64, ciphertextBase64] = encryptedKey.split(":");
  const iv = Uint8Array.from(atob(ivBase64), c => c.charCodeAt(0));
  const ciphertext = Uint8Array.from(atob(ciphertextBase64), c => c.charCodeAt(0));

  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(encryptionKey).slice(0, 32),
    { name: "AES-GCM" },
    false,
    ["decrypt"]
  );

  const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
  return new TextDecoder().decode(plaintext);
}
```

### Input Sanitization 

```ts
function sanitizeUserInput(input: string): string {
  return input
    .replace(/\[SYSTEM\]/gi, "[user_text]")
    .replace(/\[INST\]/gi, "[user_text]")
    .replace(/<\|im_start\|>/gi, "")
    .replace(/<\|im_end\|>/gi, "")
    .replace(/<<SYS>>/gi, "")
    .replace(/<\/SYS>/gi, "");
}
```

### Rate Limiting Defense 

| Limit | Value | Scope |
| :---- | :---- | :---- |
| Messages per minute | 10 | Per user |
| Concurrent model calls | 3 | Per user |
| Memory extractions per hour | 100 | Per user |
| Auto-rename attempts per hour | 10 | Per user |
| Cleanup tasks per run | 50 chats | Per user |

---

## 14.14 Observability 

### Structured Logging 

```ts
function cipherLog(level: "info" | "warn" | "error", event: string, data: Record<string, any>) {
  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    service: "cipher",
    level,
    event,
    ...data,
  }));
}
```

### Key Metrics 

| Metric | Source | Purpose |
| :---- | :---- | :---- |
| Routing latency (ms) | cipher-route | Monitor response time |
| Model API latency (ms) | cipher-route | Track model performance |
| Routing confidence distribution | cipher-route | Tune routing thresholds |
| Memory extraction rate | cipher-memory | Validate extraction quality |
| Conflict detection rate | cipher-memory | Monitor memory health |
| Skill redirect rate | cipher-route | Identify persona gaps |
| Fallback model invocation rate | cipher-route | Track primary model reliability |
| Error rate by type | All functions | Alerting and debugging |

---

*End of Part 14\. Proceed to Part 15: CogniGraph Memory System.*

# PART 15: COGNIGRAPH MEMORY SYSTEM 

---

CogniGraph is aiConnected's persistent knowledge graph. It is the system that gives personas the ability to remember decisions, facts, preferences, learned skills, and procedures across conversations. CogniGraph is not a simple key-value store — it is a graph of interconnected memory nodes with typed relationships, scoped visibility, confidence levels, and a lifecycle that moves from transient observation to permanent knowledge.

Part 9 defined the `persona_memories` table as persona-scoped knowledge nodes. Part 13 defined `instruction_memory` as user-level behavioral rules. Part 14 defined the extraction and retrieval pipelines that read and write memories. This part completes the architecture by defining the graph structure (edges between memories), conversation checkpoints (compressed context for long conversations), the full memory lifecycle from extraction through decay, multi-scope retrieval with token-budgeted ranking, and the schema extensions that unify these into a coherent knowledge graph.

---

## 15.1 Architecture Overview 

CogniGraph consists of three storage layers and two runtime processes:

```
                          ┌─────────────────────────────┐
                          │      CogniGraph Runtime      │
                          │                               │
                          │  ┌─────────┐  ┌───────────┐  │
                          │  │Extractor│  │ Retriever  │  │
                          │  │(Part 14)│  │(this part) │  │
                          │  └────┬────┘  └─────┬─────┘  │
                          └───────┼─────────────┼────────┘
                                  │             │
              ┌─────────────────────────────────────────────┐
              │               Storage Layers                 │
              │                                              │
              │  ┌────────────────────────────────────────┐  │
              │  │ Layer 1: Memory Nodes (persona_memories)│  │
              │  │  Per-persona knowledge with embeddings  │  │
              │  └────────────────────┬───────────────────┘  │
              │                       │                       │
              │  ┌────────────────────┴───────────────────┐  │
              │  │ Layer 2: Memory Edges (memory_edges)    │  │
              │  │  Relationships between nodes            │  │
              │  └────────────────────────────────────────┘  │
              │                                              │
              │  ┌────────────────────────────────────────┐  │
              │  │ Layer 3: Checkpoints (memory_checkpoints)│ │
              │  │  Compressed conversation summaries      │  │
              │  └────────────────────────────────────────┘  │
              │                                              │
              │  ┌────────────────────────────────────────┐  │
              │  │ Cross-cutting: Instruction Memory       │  │
              │  │  (Part 13 — behavioral rules)           │  │
              │  └────────────────────────────────────────┘  │
              └──────────────────────────────────────────────┘
```

**How these layers interconnect:**

Memory Nodes (`persona_memories`) are the fundamental unit of knowledge. Each node is a discrete piece of information scoped to a specific persona. Nodes have typed content, confidence levels, embeddings for semantic search, and a lifecycle layer (open or closed).

Memory Edges (`memory_edges`) connect nodes to form a graph. Edges express relationships: one memory supports another, contradicts it, is related, or is derived from it. Edges have a strength value that decays or strengthens over time based on reinforcement. The edge layer enables Cipher to retrieve not just individual memories but clusters of related knowledge.

Memory Checkpoints (`memory_checkpoints`) are compressed summaries of conversation segments. When a conversation grows long, Cipher creates checkpoints at natural boundaries (topic change, time gap, or token threshold). The context assembly pipeline (Part 14\) uses checkpoints instead of raw message history for older segments, dramatically reducing token consumption while preserving key context.

Instruction Memory (`instruction_memory`, Part 13\) operates as a separate system for explicit behavioral rules. It is not part of the graph structure but is loaded alongside CogniGraph content during context assembly.

---

## 15.2 Schema Extensions 

### Extending persona\_memories 

Part 9 defined the `persona_memories` table. CogniGraph extends it with two columns that support the memory lifecycle:

```sql
-- Extend persona_memories with lifecycle columns
ALTER TABLE public.persona_memories
  ADD COLUMN IF NOT EXISTS layer TEXT NOT NULL DEFAULT 'open'
    CHECK (layer IN ('open', 'closed')),
  ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;

-- Index for lifecycle queries
CREATE INDEX IF NOT EXISTS idx_memories_layer
  ON public.persona_memories(persona_id, layer) WHERE active = true;

-- Index for expiration cleanup
CREATE INDEX IF NOT EXISTS idx_memories_expiring
  ON public.persona_memories(expires_at) WHERE expires_at IS NOT NULL AND active = true;
```

**Column definitions:**

| Column | Type | Purpose |
| :---- | :---- | :---- |
| `layer` | TEXT | `'open'` \= transient (subject to decay), `'closed'` \= permanent (user-confirmed or reinforced). Default: `'open'`. |
| `expires_at` | TIMESTAMPTZ | Optional expiration timestamp. When set, the memory is automatically deactivated by `cipher-cleanup` after this time. Used for time-bounded observations. |

Updated TypeScript interface (extends Part 9):

```ts
interface PersonaMemory {
  id: string;
  persona_id: string;
  type: "decision" | "fact" | "preference" | "skill" | "procedure";
  content: string;
  source_chat_id: string | null;
  source_message_id: string | null;
  confidence: "strong" | "medium" | "weak";
  tags: string[];
  active: boolean;
  embedding: number[] | null;
  layer: "open" | "closed";
  expires_at: string | null;
  created_at: string;
  updated_at: string;
  // Joined
  source_chat?: { id: string; title: string };
  edges?: MemoryEdge[];
}
```

### Adding "procedure" Memory Type 

The outline specifies 5 memory types, but Part 9's CHECK constraint only includes 4 (`decision`, `fact`, `preference`, `skill`). CogniGraph adds `procedure`:

```sql
ALTER TABLE public.persona_memories
  DROP CONSTRAINT IF EXISTS persona_memories_type_check;

ALTER TABLE public.persona_memories
  ADD CONSTRAINT persona_memories_type_check
    CHECK (type IN ('decision', 'fact', 'preference', 'skill', 'procedure'));
```

### New Table: memory\_edges 

Memory edges form the graph layer of CogniGraph. Each edge connects two memory nodes with a typed relationship and a strength value.

```sql
CREATE TABLE public.memory_edges (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  from_node_id UUID NOT NULL REFERENCES public.persona_memories(id) ON DELETE CASCADE,
  to_node_id UUID NOT NULL REFERENCES public.persona_memories(id) ON DELETE CASCADE,
  relationship TEXT NOT NULL CHECK (relationship IN (
    'supports',     -- from_node reinforces to_node
    'contradicts',  -- from_node conflicts with to_node
    'related',      -- from_node is topically related to to_node
    'derived_from', -- from_node was inferred or derived from to_node
    'supersedes'    -- from_node replaces to_node (newer information)
  )),
  strength NUMERIC(3,2) NOT NULL DEFAULT 0.50
    CHECK (strength >= 0 AND strength <= 1),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(from_node_id, to_node_id, relationship)
);

ALTER TABLE public.memory_edges ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage edges on own memories"
  ON public.memory_edges FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.persona_memories pm
      JOIN public.personas p ON p.id = pm.persona_id
      WHERE pm.id = memory_edges.from_node_id
      AND p.user_id = auth.uid()
    )
  );

CREATE INDEX idx_edges_from ON public.memory_edges(from_node_id);
CREATE INDEX idx_edges_to ON public.memory_edges(to_node_id);
CREATE INDEX idx_edges_relationship ON public.memory_edges(relationship);
```

TypeScript interface:

```ts
interface MemoryEdge {
  id: string;
  from_node_id: string;
  to_node_id: string;
  relationship: "supports" | "contradicts" | "related" | "derived_from" | "supersedes";
  strength: number;
  created_at: string;
  // Joined
  from_node?: PersonaMemory;
  to_node?: PersonaMemory;
}
```

### New Table: memory\_checkpoints 

Checkpoints are compressed summaries of conversation segments. They allow Cipher to represent long conversations efficiently in the context window without including every message.

```sql
CREATE TABLE public.memory_checkpoints (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  chat_id UUID NOT NULL REFERENCES public.chats(id) ON DELETE CASCADE,
  summary TEXT NOT NULL,
  key_topics TEXT[] DEFAULT '{}',
  key_decisions TEXT[] DEFAULT '{}',
  message_range_start UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  message_range_end UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  message_count INTEGER NOT NULL DEFAULT 0,
  token_count INTEGER NOT NULL DEFAULT 0,
  checkpoint_type TEXT NOT NULL CHECK (checkpoint_type IN (
    'topic',   -- Created when conversation topic shifts
    'time',    -- Created after a time gap (e.g., >2 hours between messages)
    'token'    -- Created when a message block exceeds a token threshold
  )),
  sequence_number INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.memory_checkpoints ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view checkpoints in own chats"
  ON public.memory_checkpoints FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM public.chats
      WHERE chats.id = memory_checkpoints.chat_id
      AND chats.user_id = auth.uid()
    )
  );

-- Service role can insert (Edge Functions create checkpoints)
CREATE POLICY "Service role insert checkpoints"
  ON public.memory_checkpoints FOR INSERT
  USING (auth.role() = 'service_role');

CREATE INDEX idx_checkpoints_chat ON public.memory_checkpoints(chat_id, sequence_number);
CREATE INDEX idx_checkpoints_type ON public.memory_checkpoints(chat_id, checkpoint_type);
```

TypeScript interface:

```ts
interface MemoryCheckpoint {
  id: string;
  chat_id: string;
  summary: string;
  key_topics: string[];
  key_decisions: string[];
  message_range_start: string | null;
  message_range_end: string | null;
  message_count: number;
  token_count: number;
  checkpoint_type: "topic" | "time" | "token";
  sequence_number: number;
  created_at: string;
}
```

---

## 15.3 Memory Types 

CogniGraph recognizes 5 distinct memory types. Each type serves a different role in how the persona understands and recalls information.

### Decision 

A choice that was made during a conversation. Decisions represent the outcome of a deliberation — they capture what was chosen and implicitly exclude alternatives.

| Property | Value |
| :---- | :---- |
| Icon | GitBranch |
| Color accent | `#818cf8` (indigo-400) |
| Typical confidence | strong |
| Default layer | closed (decisions are typically final) |
| Extraction trigger | Phrases like "let's go with", "we decided", "the choice is", "I'll use" |

**Examples:**

- "We chose the asymmetric layout for the hero section"  
- "Client approved the navy \+ gold color scheme"  
- "Using Next.js 15 with app router for this project"

### Fact 

An objective piece of information that is verifiably true or was stated as truth.

| Property | Value |
| :---- | :---- |
| Icon | Brain |
| Color accent | `#34d399` (emerald-400) |
| Typical confidence | medium → strong (upgrades on reinforcement) |
| Default layer | open (may change if correction arrives) |
| Extraction trigger | Statements of fact, data points, definitions, specifications |

**Examples:**

- "Client's brand color is \#1a1a2e"  
- "The API rate limit is 100 requests per minute"  
- "Project deadline is March 15th"

### Preference 

A user or instance preference that influences future behavior. Preferences shape how the persona communicates and what it prioritizes.

| Property | Value |
| :---- | :---- |
| Icon | Heart |
| Color accent | `#f472b6` (pink-400) |
| Typical confidence | weak → medium (upgrades on repetition) |
| Default layer | open |
| Extraction trigger | "I prefer", "I like", "don't use", "always do X", consistent behavior patterns |

**Examples:**

- "Bob prefers concise responses over long explanations"  
- "Use British English spelling in all documents"  
- "Client dislikes rounded corners on buttons"

### Skill 

A learned capability that the persona demonstrated or was taught. Skills represent what the persona can do, distinct from persona\_skills (Part 9\) which are declared capabilities. Skill memories track instances where the persona actually executed something.

| Property | Value |
| :---- | :---- |
| Icon | Zap |
| Color accent | `#fbbf24` (amber-400) |
| Typical confidence | medium |
| Default layer | open (closes after 3+ successful demonstrations) |
| Extraction trigger | Successful task completion, "you're good at", repeated execution patterns |

**Examples:**

- "Can analyze competitor pricing from raw data"  
- "Successfully converted Figma designs to React components"  
- "Can write SQL queries for complex reporting dashboards"

### Procedure 

How to do something — a step-by-step process or workflow that was established during a conversation. Procedures are the most structured memory type and are often created from explicit instructions.

| Property | Value |
| :---- | :---- |
| Icon | ListOrdered |
| Color accent | `#38bdf8` (sky-400) |
| Typical confidence | strong (explicit), medium (inferred) |
| Default layer | closed (procedures are typically authoritative) |
| Extraction trigger | Step-by-step instructions, "when X happens do Y", established workflows |

**Examples:**

- "When uploading files, always rename to kebab-case"  
- "Code review process: lint → test → manual review → merge"  
- "Weekly report structure: summary, metrics, blockers, next steps"

### Updated Extraction Prompt 

The memory extraction prompt in Part 14 should use this updated classification. Here is the refined extraction prompt that Cipher uses in `cipher-memory`:

```ts
const MEMORY_EXTRACTION_PROMPT = `Analyze this conversation exchange and extract any new knowledge worth remembering.

User said: "{userMessage}"

Assistant responded: "{personaResponse}"

For each piece of knowledge, classify it as one of:
- decision: A choice or selection that was made
- fact: An objective piece of information or data point
- preference: A user preference, opinion, or behavioral directive
- skill: Something the persona demonstrated ability to do
- procedure: A process, workflow, or step-by-step method

For each extraction, also identify:
- confidence: "strong" (explicitly stated), "medium" (clearly implied), "weak" (loosely inferred)
- layer: "closed" (definitive — decisions, confirmed facts, established procedures) or "open" (may change — preferences, observations, inferred skills)

Respond ONLY with a JSON array. Each item:
{
  "type": "decision" | "fact" | "preference" | "skill" | "procedure",
  "content": "A concise statement of the knowledge",
  "confidence": "strong" | "medium" | "weak",
  "layer": "open" | "closed"
}

Rules:
- Do NOT extract trivial information (greetings, acknowledgments, small talk)
- Do NOT extract information that is purely temporary context ("I'm working on this right now")
- DO extract information that would be useful in future conversations
- Keep content concise — one clear statement per item
- If there is nothing worth extracting, respond with an empty array: []`;
```

---

## 15.4 Memory Lifecycle 

Every memory node follows a defined lifecycle. The lifecycle determines how memories transition between states, grow or decay in confidence, and eventually become permanent knowledge or expire.

```
                    ┌─────────────┐
                    │  Extraction  │  Cipher detects memorable content
                    └──────┬──────┘
                           │
                           ▼
                    ┌─────────────┐
                    │  Open Layer  │  Transient — subject to decay
                    │  (default)   │
                    └──────┬──────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
              ▼            ▼            ▼
        ┌───────────┐ ┌────────┐ ┌──────────┐
        │ Reinforced │ │Decayed │ │Conflicted│
        │ (>3 refs)  │ │(no use)│ │(mismatch)│
        └─────┬─────┘ └───┬────┘ └────┬─────┘
              │            │           │
              ▼            ▼           ▼
       ┌────────────┐ ┌────────┐ ┌──────────┐
       │Closed Layer │ │Inactive│ │ Flagged  │
       │(permanent)  │ │(hidden)│ │(review)  │
       └─────────────┘ └───┬────┘ └────┬─────┘
                           │           │
                           ▼           ▼
                      ┌────────┐  ┌────────┐
                      │Deleted │  │Resolved│
                      │        │  │(merge) │
                      └────────┘  └────────┘
```

### Stage 1: Extraction 

After every AI response, `cipher-memory` (Part 14\) analyzes the conversation exchange and creates candidate memory nodes. Each extracted memory is inserted into `persona_memories` with:

- `layer = 'open'` (default) or `'closed'` (for decisions and explicit procedures)  
- `confidence` as determined by the extraction model  
- `active = true`  
- `embedding` generated from the memory content  
- `source_chat_id` and `source_message_id` linking back to the originating conversation

**Extraction rate:** Cipher extracts memories selectively. Not every message produces memories. Typical extraction rates:

- Short exchanges (1–2 turns): 0–1 memories  
- Working sessions (5–10 turns): 1–4 memories  
- Deep collaboration (20+ turns): 3–8 memories

### Stage 2: Open Layer (Transient) 

Open-layer memories are the default state for new extractions. They are included in context retrieval but are subject to confidence decay over time. Open memories can transition in three directions:

**Promotion to Closed** (reinforcement): When an open memory is retrieved and used in a subsequent conversation (matched via semantic search with similarity \> 0.7), its confidence is reinforced:

```ts
async function reinforceMemory(
  client: SupabaseClient,
  memoryId: string,
  currentConfidence: string
) {
  const promotionRules: Record<string, { next: string; promoteToClosed: boolean }> = {
    weak: { next: "medium", promoteToClosed: false },
    medium: { next: "strong", promoteToClosed: false },
    strong: { next: "strong", promoteToClosed: true },
  };

  const rule = promotionRules[currentConfidence] ?? { next: currentConfidence, promoteToClosed: false };

  const updates: Record<string, any> = {
    confidence: rule.next,
    updated_at: new Date().toISOString(),
  };

  if (rule.promoteToClosed) {
    updates.layer = "closed";
  }

  await client
    .from("persona_memories")
    .update(updates)
    .eq("id", memoryId);
}
```

**Reinforcement triggers:**

1. Memory is retrieved during context assembly (similarity \> 0.7 with the current user message)  
2. Memory is explicitly referenced in the conversation ("as we discussed before")  
3. User confirms a memory via the Memory tab UI (Part 9\)

**Confidence escalation path:**

```
weak → medium → strong → closed
```

Each promotion requires at least one reinforcement event. A memory promoted from `strong` to `closed` moves from the open layer to the closed layer, becoming permanent.

**Decay** (no reinforcement): Open-layer memories that are not retrieved or reinforced within a time window have their confidence decayed by `cipher-cleanup` (Part 14):

| Current Confidence | Decay After | New Confidence | Next Decay |
| :---- | :---- | :---- | :---- |
| strong | 60 days unreferenced | medium | 30 more days |
| medium | 30 days unreferenced | weak | 30 more days |
| weak | 30 days unreferenced | deactivated (active \= false) | — |

Decay function (runs in `cipher-cleanup`):

```ts
async function decayOpenMemories(client: SupabaseClient, userId: string) {
  const now = new Date();

  // Strong → Medium (60 days)
  const sixtyDaysAgo = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString();
  await client
    .from("persona_memories")
    .update({ confidence: "medium", updated_at: now.toISOString() })
    .in("persona_id", client.from("personas").select("id").eq("user_id", userId))
    .eq("layer", "open")
    .eq("confidence", "strong")
    .eq("active", true)
    .lt("updated_at", sixtyDaysAgo);

  // Medium → Weak (30 days)
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
  await client
    .from("persona_memories")
    .update({ confidence: "weak", updated_at: now.toISOString() })
    .in("persona_id", client.from("personas").select("id").eq("user_id", userId))
    .eq("layer", "open")
    .eq("confidence", "medium")
    .eq("active", true)
    .lt("updated_at", thirtyDaysAgo);

  // Weak → Deactivated (30 days)
  await client
    .from("persona_memories")
    .update({ active: false, updated_at: now.toISOString() })
    .in("persona_id", client.from("personas").select("id").eq("user_id", userId))
    .eq("layer", "open")
    .eq("confidence", "weak")
    .eq("active", true)
    .lt("updated_at", thirtyDaysAgo);
}
```

**Conflict detection** is covered in Section 15.6.

### Stage 3: Closed Layer (Permanent) 

Closed-layer memories are permanent knowledge. They are not subject to confidence decay. Closed memories can only be removed by explicit user action (delete) or by being superseded by a newer memory.

Memories can be closed by:

1. Automatic promotion (reinforced through `strong` confidence 3+ times)  
2. Extraction with `layer = 'closed'` (decisions, explicit procedures)  
3. User manually closing via the Memory tab (Part 9 — "Lock" action)  
4. Cipher auto-closing when a memory matches an instruction\_memory rule

### Stage 4: Deactivation and Deletion 

**Deactivation:** Setting `active = false`. The memory remains in the database (for audit and graph integrity) but is excluded from context retrieval. Deactivation happens via:

- Confidence decay (weak, unreferenced for 30 days)  
- User toggles "active" off in the Memory tab  
- Expiration (`expires_at` reached)  
- Superseded by a newer memory

**Deletion:** Hard-deleting the memory row. This cascades to delete all associated edges. Deletion happens via:

- User explicitly deletes in the Memory tab  
- Batch delete via selection mode  
- `cipher-cleanup` purges deactivated memories older than 90 days

```ts
async function purgeDeactivatedMemories(client: SupabaseClient, userId: string) {
  const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();

  await client
    .from("persona_memories")
    .delete()
    .in("persona_id", client.from("personas").select("id").eq("user_id", userId))
    .eq("active", false)
    .lt("updated_at", ninetyDaysAgo);
}
```

### Stage 5: Expiration 

Memories with `expires_at` set are automatically deactivated when the timestamp passes. This is used for time-bounded facts:

- "The deployment freeze is until Friday" → `expires_at` \= next Friday  
- "We're pausing the redesign until Q2" → `expires_at` \= April 1

Cipher can infer expiration dates from temporal references in extracted content:

```ts
function inferExpiration(memoryContent: string): string | null {
  const today = new Date();

  // "until Friday" / "by Friday"
  const dayMatch = memoryContent.match(
    /\b(?:until|by|before)\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/i
  );
  if (dayMatch) {
    const targetDay = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]
      .indexOf(dayMatch[1].toLowerCase());
    const daysUntil = (targetDay - today.getDay() + 7) % 7 || 7;
    const expiry = new Date(today.getTime() + daysUntil * 24 * 60 * 60 * 1000);
    expiry.setUTCHours(23, 59, 59, 999);
    return expiry.toISOString();
  }

  // "until Q2" / "until Q3"
  const quarterMatch = memoryContent.match(/\buntil\s+Q([1-4])\b/i);
  if (quarterMatch) {
    const quarter = parseInt(quarterMatch[1]);
    const year = today.getFullYear();
    const quarterStartMonth = (quarter - 1) * 3;
    return new Date(year, quarterStartMonth, 1).toISOString();
  }

  // "for the next X days/weeks"
  const durationMatch = memoryContent.match(/\bfor\s+(?:the\s+)?next\s+(\d+)\s+(days?|weeks?|months?)\b/i);
  if (durationMatch) {
    const amount = parseInt(durationMatch[1]);
    const unit = durationMatch[2].toLowerCase();
    const ms = unit.startsWith("day") ? amount * 86400000
      : unit.startsWith("week") ? amount * 7 * 86400000
      : amount * 30 * 86400000;
    return new Date(today.getTime() + ms).toISOString();
  }

  return null; // No temporal reference detected
}
```

---

## 15.5 Memory Edges 

Edges transform the flat list of memories into a knowledge graph. They enable Cipher to retrieve not just individually relevant memories but clusters of interconnected knowledge.

### Relationship Types 

| Relationship | Direction | Meaning | Typical Strength |
| :---- | :---- | :---- | :---- |
| `supports` | A → B | A provides evidence for or reinforces B | 0.6–0.9 |
| `contradicts` | A → B | A conflicts with or invalidates B | 0.7–1.0 |
| `related` | A ↔ B | A and B are topically related but independent | 0.3–0.7 |
| `derived_from` | A → B | A was inferred or created based on B | 0.5–0.8 |
| `supersedes` | A → B | A is newer information that replaces B | 0.8–1.0 |

### Edge Creation 

Edges are created automatically during memory extraction (Part 14). After inserting a new memory node, Cipher checks for related existing memories and creates appropriate edges:

```ts
async function createMemoryEdges(
  client: SupabaseClient,
  newMemoryId: string,
  personaId: string,
  embedding: number[],
  content: string
) {
  // Find semantically similar existing memories
  const { data: similar } = await client.rpc("search_persona_memories", {
    p_persona_id: personaId,
    p_embedding: embedding,
    p_match_threshold: 0.6,
    p_match_count: 10,
  });

  if (!similar || similar.length === 0) return;

  for (const existing of similar) {
    if (existing.id === newMemoryId) continue;

    const relationship = classifyRelationship(content, existing.content, existing.similarity);
    if (!relationship) continue;

    // Check for duplicate edge
    const { count } = await client
      .from("memory_edges")
      .select("id", { count: "exact", head: true })
      .eq("from_node_id", newMemoryId)
      .eq("to_node_id", existing.id)
      .eq("relationship", relationship.type);

    if ((count ?? 0) > 0) continue;

    await client.from("memory_edges").insert({
      from_node_id: newMemoryId,
      to_node_id: existing.id,
      relationship: relationship.type,
      strength: relationship.strength,
    });
  }
}

function classifyRelationship(
  newContent: string,
  existingContent: string,
  similarity: number
): { type: string; strength: number } | null {
  const newLower = newContent.toLowerCase();
  const existingLower = existingContent.toLowerCase();

  // High similarity + negation = contradiction
  if (similarity > 0.7) {
    const negationWords = ["not", "no longer", "changed", "instead", "rather than", "replaced", "switched", "actually", "corrected"];
    const hasNegation = negationWords.some(w => newLower.includes(w));

    if (hasNegation) {
      return { type: "contradicts", strength: Math.min(similarity + 0.1, 1.0) };
    }
  }

  // Very high similarity = supports or supersedes
  if (similarity > 0.85) {
    // Check if the new memory adds specificity
    if (newContent.length > existingContent.length * 1.3) {
      return { type: "supersedes", strength: 0.8 };
    }
    return { type: "supports", strength: similarity };
  }

  // Moderate similarity = related
  if (similarity > 0.6) {
    return { type: "related", strength: similarity * 0.8 };
  }

  return null; // Too dissimilar for an edge
}
```

### Edge Strength Dynamics 

Edge strength is not static. It changes based on usage:

**Reinforcement:** When both nodes connected by an edge are retrieved together in the same context assembly, the edge strength increases:

```ts
async function reinforceEdge(client: SupabaseClient, edgeId: string, currentStrength: number) {
  const newStrength = Math.min(currentStrength + 0.05, 1.0);
  await client
    .from("memory_edges")
    .update({ strength: newStrength })
    .eq("id", edgeId);
}
```

**Decay:** Edges connected to decayed memories have their strength reduced proportionally. When a memory's confidence drops, edges connected to it lose strength:

```ts
async function decayEdgesForMemory(client: SupabaseClient, memoryId: string) {
  const { data: edges } = await client
    .from("memory_edges")
    .select("id, strength")
    .or(`from_node_id.eq.${memoryId},to_node_id.eq.${memoryId}`);

  for (const edge of edges ?? []) {
    const newStrength = Math.max(edge.strength - 0.1, 0);
    if (newStrength <= 0) {
      await client.from("memory_edges").delete().eq("id", edge.id);
    } else {
      await client.from("memory_edges").update({ strength: newStrength }).eq("id", edge.id);
    }
  }
}
```

### Edge Traversal for Retrieval 

During context assembly, after retrieving the top-K memories via semantic search, Cipher follows edges to pull in supporting context:

```ts
async function expandWithEdges(
  client: SupabaseClient,
  seedMemoryIds: string[],
  tokenBudgetRemaining: number,
  maxHops: number = 1
): Promise<PersonaMemory[]> {
  const visited = new Set(seedMemoryIds);
  const expanded: PersonaMemory[] = [];
  let currentBudget = tokenBudgetRemaining;

  // One hop from seed memories
  for (const memoryId of seedMemoryIds) {
    if (currentBudget <= 0) break;

    const { data: edges } = await client
      .from("memory_edges")
      .select(`
        to_node_id,
        relationship,
        strength,
        to_node:persona_memories!memory_edges_to_node_id_fkey(*)
      `)
      .eq("from_node_id", memoryId)
      .in("relationship", ["supports", "derived_from"])
      .gt("strength", 0.5)
      .order("strength", { ascending: false })
      .limit(3);

    for (const edge of edges ?? []) {
      if (visited.has(edge.to_node_id)) continue;
      if (!edge.to_node || !edge.to_node.active) continue;

      const tokens = estimateTokens(edge.to_node.content);
      if (tokens > currentBudget) continue;

      visited.add(edge.to_node_id);
      expanded.push(edge.to_node);
      currentBudget -= tokens;
    }
  }

  return expanded;
}
```

Only `supports` and `derived_from` edges are followed during retrieval — `contradicts` edges are used for conflict detection, not context expansion. `related` edges are used with lower priority when the token budget allows.

---

## 15.6 Conflict Detection and Resolution 

When Cipher extracts a new memory that contradicts an existing one, the conflict system activates. Conflicts are surfaced to the user for resolution rather than being auto-resolved, because the system cannot always determine which memory is correct.

### Detection During Extraction 

Part 14 defined the basic conflict check. CogniGraph extends this with edge-based detection:

```ts
async function detectConflicts(
  client: SupabaseClient,
  newMemoryId: string,
  personaId: string,
  embedding: number[],
  content: string
): Promise<ConflictResult[]> {
  const conflicts: ConflictResult[] = [];

  // Semantic similarity check
  const { data: similar } = await client.rpc("search_persona_memories", {
    p_persona_id: personaId,
    p_embedding: embedding,
    p_match_threshold: 0.7,
    p_match_count: 10,
  });

  if (!similar) return [];

  for (const existing of similar) {
    if (existing.id === newMemoryId) continue;

    // Check for contradiction indicators
    const isContradiction = analyzeContradiction(content, existing.content);

    if (isContradiction.likely) {
      // Create a "contradicts" edge
      await client.from("memory_edges").insert({
        from_node_id: newMemoryId,
        to_node_id: existing.id,
        relationship: "contradicts",
        strength: isContradiction.confidence,
      });

      // Tag both memories
      await tagMemoryAsConflicted(client, existing.id);
      await tagMemoryAsConflicted(client, newMemoryId);

      conflicts.push({
        newMemoryId,
        existingMemoryId: existing.id,
        existingContent: existing.content,
        similarity: existing.similarity,
        confidence: isContradiction.confidence,
      });
    }
  }

  return conflicts;
}

interface ConflictResult {
  newMemoryId: string;
  existingMemoryId: string;
  existingContent: string;
  similarity: number;
  confidence: number;
}

function analyzeContradiction(
  newContent: string,
  existingContent: string
): { likely: boolean; confidence: number } {
  const newLower = newContent.toLowerCase();
  const existingLower = existingContent.toLowerCase();

  // Direct negation patterns
  const negationPatterns = [
    { pattern: /\bnot\b/, weight: 0.3 },
    { pattern: /\bno longer\b/, weight: 0.8 },
    { pattern: /\bchanged\b/, weight: 0.6 },
    { pattern: /\binstead\b/, weight: 0.7 },
    { pattern: /\breplaced\b/, weight: 0.8 },
    { pattern: /\bswitched\b/, weight: 0.7 },
    { pattern: /\bactually\b/, weight: 0.4 },
    { pattern: /\bcorrect(?:ion|ed)\b/, weight: 0.7 },
    { pattern: /\bwrong\b/, weight: 0.6 },
    { pattern: /\bupdated?\b/, weight: 0.5 },
  ];

  let score = 0;
  for (const { pattern, weight } of negationPatterns) {
    if (pattern.test(newLower)) score += weight;
  }

  // Opposing values in similar contexts
  // E.g., "brand color is #1a1a2e" vs "brand color is #2e95f3"
  const hexMatch = newLower.match(/#[0-9a-f]{6}/);
  const existingHexMatch = existingLower.match(/#[0-9a-f]{6}/);
  if (hexMatch && existingHexMatch && hexMatch[0] !== existingHexMatch[0]) {
    score += 0.5;
  }

  // Opposing numbers in similar contexts
  const numberMatch = newLower.match(/\b\d+\b/g);
  const existingNumberMatch = existingLower.match(/\b\d+\b/g);
  if (numberMatch && existingNumberMatch) {
    const newNums = new Set(numberMatch);
    const existingNums = new Set(existingNumberMatch);
    const hasOverlap = [...newNums].some(n => existingNums.has(n));
    if (!hasOverlap && newNums.size > 0 && existingNums.size > 0) {
      score += 0.3;
    }
  }

  return {
    likely: score >= 0.5,
    confidence: Math.min(score, 1.0),
  };
}

async function tagMemoryAsConflicted(client: SupabaseClient, memoryId: string) {
  const { data } = await client
    .from("persona_memories")
    .select("tags")
    .eq("id", memoryId)
    .single();

  const currentTags = data?.tags ?? [];
  if (currentTags.includes("conflict")) return;

  await client
    .from("persona_memories")
    .update({ tags: [...currentTags, "conflict"] })
    .eq("id", memoryId);
}
```

### Conflict Notification 

When conflicts are detected, the client is notified via Supabase Realtime. The Memory tab in the persona detail (Part 9\) shows a conflict indicator on affected memories.

**Client-side conflict badge** (appended to memory metadata row):

```ts
// Displayed when memory.tags includes "conflict"
{
  display: "inline-flex",
  alignItems: "center",
  gap: 3,
  padding: "2px 6px",
  background: "rgba(239,68,68,0.1)",
  borderRadius: 6,
  fontSize: 9,
  color: "#ef4444",
  fontWeight: 500,
}
// AlertTriangle icon (8px) + "Conflict"
```

### Conflict Resolution Actions 

Users resolve conflicts via the Memory tab. When a memory is tagged as conflicted, clicking the conflict badge reveals the conflicting memory and resolution options:

```ts
// Conflict resolution panel — appears below the conflicted memory
{
  marginTop: 8,
  padding: "10px 14px",
  background: "rgba(239,68,68,0.04)",
  borderRadius: 8,
  border: `1px solid rgba(239,68,68,0.15)`,
}
```

**Header:** "This conflicts with:" (11px, weight 450, `#ef4444`)

**Conflicting memory content** (12px, weight 350, `textMuted`, marginTop 6px, padding 8px, surfaceAlt bg, borderRadius 6\)

**Resolution buttons** (flex, gap 6px, marginTop 10px):

| Button | Action | Implementation |
| :---- | :---- | :---- |
| "Keep this one" | Deactivate the other memory, remove conflict tags | Set other memory `active = false`, remove "conflict" tag from both, change edge to `supersedes` |
| "Keep other" | Deactivate this memory, remove conflict tags | Set this memory `active = false`, same cleanup |
| "Keep both" | Remove conflict tags, downgrade edge to `related` | Remove "conflict" tags, update edge relationship to `related` |
| "Merge" | Open editor with both texts combined | User edits into a single memory, old one deactivated |

```ts
async function resolveConflict(
  client: SupabaseClient,
  resolution: "keep_this" | "keep_other" | "keep_both" | "merge",
  thisMemoryId: string,
  otherMemoryId: string,
  mergedContent?: string
) {
  switch (resolution) {
    case "keep_this":
      await client.from("persona_memories").update({ active: false }).eq("id", otherMemoryId);
      await cleanConflictTags(client, thisMemoryId, otherMemoryId);
      await updateEdgeRelationship(client, thisMemoryId, otherMemoryId, "supersedes");
      break;

    case "keep_other":
      await client.from("persona_memories").update({ active: false }).eq("id", thisMemoryId);
      await cleanConflictTags(client, thisMemoryId, otherMemoryId);
      await updateEdgeRelationship(client, otherMemoryId, thisMemoryId, "supersedes");
      break;

    case "keep_both":
      await cleanConflictTags(client, thisMemoryId, otherMemoryId);
      await updateEdgeRelationship(client, thisMemoryId, otherMemoryId, "related");
      break;

    case "merge":
      if (mergedContent) {
        await client.from("persona_memories")
          .update({ content: mergedContent, updated_at: new Date().toISOString() })
          .eq("id", thisMemoryId);
        await client.from("persona_memories").update({ active: false }).eq("id", otherMemoryId);
        await cleanConflictTags(client, thisMemoryId, otherMemoryId);
        // Re-generate embedding for the merged content
        const embedding = await generateEmbedding(mergedContent);
        await client.from("persona_memories")
          .update({ embedding })
          .eq("id", thisMemoryId);
      }
      break;
  }
}

async function cleanConflictTags(client: SupabaseClient, id1: string, id2: string) {
  for (const id of [id1, id2]) {
    const { data } = await client.from("persona_memories").select("tags").eq("id", id).single();
    const cleaned = (data?.tags ?? []).filter((t: string) => t !== "conflict");
    await client.from("persona_memories").update({ tags: cleaned }).eq("id", id);
  }
}

async function updateEdgeRelationship(
  client: SupabaseClient,
  fromId: string,
  toId: string,
  newRelationship: string
) {
  await client
    .from("memory_edges")
    .update({ relationship: newRelationship })
    .or(`and(from_node_id.eq.${fromId},to_node_id.eq.${toId}),and(from_node_id.eq.${toId},to_node_id.eq.${fromId})`)
    .eq("relationship", "contradicts");
}
```

---

## 15.7 Memory Checkpoints 

Checkpoints compress conversation history into summaries. They solve the fundamental problem of long conversations: the full message history exceeds the model's context window. Instead of dropping old messages, Cipher creates summaries at natural boundaries and uses those summaries during context assembly.

### Checkpoint Triggers 

| Trigger | Condition | Type |
| :---- | :---- | :---- |
| Topic change | Cipher detects the conversation has shifted topics (embedding similarity between current message cluster and previous cluster drops below 0.4) | `topic` |
| Time gap | More than 2 hours between consecutive messages | `time` |
| Token threshold | The un-checkpointed message block exceeds 3,000 tokens | `token` |

### Checkpoint Creation 

Checkpoints are created by `cipher-memory` as part of the post-response pipeline (Part 14). After every response, Cipher checks if any checkpoint trigger has been met:

````ts
async function checkAndCreateCheckpoint(
  client: SupabaseClient,
  chatId: string,
  latestMessageId: string
) {
  // Find the last checkpoint for this chat
  const { data: lastCheckpoint } = await client
    .from("memory_checkpoints")
    .select("*")
    .eq("chat_id", chatId)
    .order("sequence_number", { ascending: false })
    .limit(1)
    .single();

  // Get messages since the last checkpoint
  let query = client
    .from("messages")
    .select("*")
    .eq("chat_id", chatId)
    .order("created_at", { ascending: true });

  if (lastCheckpoint?.message_range_end) {
    // Get the creation time of the checkpoint's last message
    const { data: endMsg } = await client
      .from("messages")
      .select("created_at")
      .eq("id", lastCheckpoint.message_range_end)
      .single();

    if (endMsg) {
      query = query.gt("created_at", endMsg.created_at);
    }
  }

  const { data: uncheckedMessages } = await query;
  if (!uncheckedMessages || uncheckedMessages.length < 4) return; // Too few messages

  // Check triggers
  const trigger = detectCheckpointTrigger(uncheckedMessages);
  if (!trigger) return;

  // Generate summary using a lightweight model
  const messagesText = uncheckedMessages
    .map(m => `${m.sender_name}: ${m.content.slice(0, 300)}`)
    .join("\n");

  const summaryClient = new OpenAI({
    apiKey: Deno.env.get("OPENROUTER_API_KEY")!,
    baseURL: "https://openrouter.ai/api/v1",
  });

  const response = await summaryClient.chat.completions.create({
    model: "claude-haiku-4-5-20251001",
    messages: [{
      role: "user",
      content: `Summarize this conversation segment concisely (2-4 sentences). Focus on:
1. Key topics discussed
2. Decisions made
3. Unresolved questions
4. Action items

Conversation:
${messagesText}

Also provide:
- key_topics: comma-separated list of main topics (max 5)
- key_decisions: comma-separated list of any decisions made (may be empty)

Format your response as JSON:
{
  "summary": "...",
  "key_topics": ["topic1", "topic2"],
  "key_decisions": ["decision1"]
}`,
    }],
    max_tokens: 300,
  });

  const responseText = response.choices[0]?.message?.content ?? "";

  let parsed: { summary: string; key_topics: string[]; key_decisions: string[] };
  try {
    const cleaned = responseText.replace(/```json\n?|```/g, "").trim();
    parsed = JSON.parse(cleaned);
  } catch {
    parsed = { summary: responseText.slice(0, 500), key_topics: [], key_decisions: [] };
  }

  // Calculate token count of the raw messages
  const tokenCount = uncheckedMessages.reduce(
    (sum, m) => sum + estimateTokens(m.content), 0
  );

  const nextSequence = (lastCheckpoint?.sequence_number ?? 0) + 1;

  await client.from("memory_checkpoints").insert({
    chat_id: chatId,
    summary: parsed.summary,
    key_topics: parsed.key_topics,
    key_decisions: parsed.key_decisions,
    message_range_start: uncheckedMessages[0].id,
    message_range_end: uncheckedMessages[uncheckedMessages.length - 1].id,
    message_count: uncheckedMessages.length,
    token_count: tokenCount,
    checkpoint_type: trigger,
    sequence_number: nextSequence,
  });
}
````

### Trigger Detection 

```ts
function detectCheckpointTrigger(messages: Message[]): "topic" | "time" | "token" | null {
  if (messages.length < 4) return null;

  // Check token threshold first (simplest)
  const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m.content), 0);
  if (totalTokens > 3000) return "token";

  // Check time gap
  for (let i = 1; i < messages.length; i++) {
    const gap = new Date(messages[i].created_at).getTime()
      - new Date(messages[i - 1].created_at).getTime();
    const twoHoursMs = 2 * 60 * 60 * 1000;
    if (gap > twoHoursMs) return "time";
  }

  // Topic change detection is more expensive — skip for v1
  // In v2, this would compare embeddings of message clusters
  // For now, use a heuristic: if we have 15+ messages without a checkpoint, create one
  if (messages.length >= 15) return "token";

  return null;
}
```

### Checkpoint Usage in Context Assembly 

The context assembly pipeline (Part 14, Section 14.6) uses checkpoints to represent older conversation history. This section defines the enhanced history builder that integrates checkpoints:

```ts
async function buildConversationHistoryWithCheckpoints(
  client: SupabaseClient,
  chatId: string,
  recentMessages: Message[],
  tokenBudget: number
): Promise<{ role: "user" | "assistant" | "system"; content: string }[]> {
  const history: { role: "user" | "assistant" | "system"; content: string }[] = [];
  let remainingBudget = tokenBudget;

  // Phase 1: Include recent messages (newest first, up to 60% of budget)
  const recentBudget = Math.floor(tokenBudget * 0.6);
  let recentTokens = 0;

  for (let i = recentMessages.length - 1; i >= 0; i--) {
    const msg = recentMessages[i];
    if (msg.sender_type === "system" || msg.sender_type === "cipher") continue;

    const role = msg.sender_type === "user" ? "user" as const : "assistant" as const;
    const content = msg.sender_type === "persona"
      ? `[${msg.sender_name}]: ${msg.content}`
      : msg.content;
    const tokens = estimateTokens(content);

    if (recentTokens + tokens > recentBudget) break;

    history.unshift({ role, content });
    recentTokens += tokens;
  }

  remainingBudget -= recentTokens;

  // Phase 2: Fill remaining budget with checkpoint summaries (oldest first)
  if (remainingBudget > 200) {
    const { data: checkpoints } = await client
      .from("memory_checkpoints")
      .select("*")
      .eq("chat_id", chatId)
      .order("sequence_number", { ascending: true });

    if (checkpoints && checkpoints.length > 0) {
      const checkpointSummaries: string[] = [];
      let checkpointTokens = 0;

      for (const cp of checkpoints) {
        const summaryText = `[Conversation checkpoint — ${cp.checkpoint_type}]: ${cp.summary}`;
        const tokens = estimateTokens(summaryText);

        if (checkpointTokens + tokens > remainingBudget) break;

        checkpointSummaries.push(summaryText);
        checkpointTokens += tokens;
      }

      if (checkpointSummaries.length > 0) {
        // Insert checkpoint summaries before recent messages
        const checkpointBlock = checkpointSummaries.join("\n\n");
        history.unshift({
          role: "system" as const,
          content: `[Earlier conversation context — summarized from ${checkpoints.length} segments]:\n\n${checkpointBlock}`,
        });
      }
    }
  }

  return history;
}
```

### Checkpoint Usage in Chat Navigation 

Part 5 references checkpoints as the data source for the right panel's Nav tab. Each checkpoint produces a navigable section:

```ts
const { data: checkpoints } = useQuery({
  queryKey: ["chat-checkpoints", chatId],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("memory_checkpoints")
      .select("*")
      .eq("chat_id", chatId)
      .order("sequence_number", { ascending: true });
    return data ?? [];
  },
});

// Render as nav sections
checkpoints.map(cp => ({
  label: cp.key_topics.slice(0, 2).join(", ") || `Segment ${cp.sequence_number}`,
  summary: cp.summary,
  messageId: cp.message_range_start, // Scroll target
}));
```

---

## 15.8 Multi-Scope Retrieval 

CogniGraph retrieves memories from multiple scopes and merges them into a single ranked list for context assembly. The scoping system ensures that the most contextually relevant memories are prioritized.

### Scope Hierarchy 

```
┌────────────────────────────────────────┐
│  Scope 1: Persona-specific             │  Highest priority
│  Memories created by/for this persona  │
├────────────────────────────────────────┤
│  Scope 2: Instance-level               │  Medium priority
│  Memories from any persona in the      │
│  same Instance as the current chat     │
├────────────────────────────────────────┤
│  Scope 3: Global                       │  Lower priority
│  Instruction memory rules + memories   │
│  from other instances by same persona  │
└────────────────────────────────────────┘
```

### Retrieval Pipeline 

The retrieval pipeline runs during context assembly (Part 14, Section 14.6). This section provides the complete implementation that replaces the simplified `retrieveMemories` function from Part 14:

```ts
interface RetrievalConfig {
  personaId: string;
  instanceId: string | null;
  userMessage: string;
  tokenBudget: number;
}

interface RankedMemory {
  memory: PersonaMemory;
  score: number;
  scope: "persona" | "instance" | "global";
  edgeExpansion: boolean;
}

async function retrieveMemoriesMultiScope(
  client: SupabaseClient,
  config: RetrievalConfig
): Promise<string> {
  const embedding = await generateEmbedding(config.userMessage);

  // Phase 1: Persona-scoped memories (primary)
  const personaMemories = await searchMemories(
    client, config.personaId, embedding, 0.5, 15
  );

  // Phase 2: Instance-scoped memories (from other personas in the same instance)
  let instanceMemories: any[] = [];
  if (config.instanceId) {
    instanceMemories = await searchInstanceMemories(
      client, config.instanceId, config.personaId, embedding, 0.6, 10
    );
  }

  // Phase 3: Rank and merge
  const allMemories: RankedMemory[] = [];

  for (const m of personaMemories) {
    allMemories.push({
      memory: m,
      score: computeRetrievalScore(m.similarity, m.created_at, m.confidence, m.layer, "persona"),
      scope: "persona",
      edgeExpansion: false,
    });
  }

  for (const m of instanceMemories) {
    allMemories.push({
      memory: m,
      score: computeRetrievalScore(m.similarity, m.created_at, m.confidence, m.layer, "instance"),
      scope: "instance",
      edgeExpansion: false,
    });
  }

  // Sort by score descending
  allMemories.sort((a, b) => b.score - a.score);

  // Phase 4: Token-budgeted selection
  let totalTokens = 0;
  const selected: RankedMemory[] = [];
  const selectedIds: string[] = [];

  for (const rm of allMemories) {
    const text = formatMemoryForContext(rm);
    const tokens = estimateTokens(text);
    if (totalTokens + tokens > config.tokenBudget * 0.8) break; // Reserve 20% for edge expansion
    selected.push(rm);
    selectedIds.push(rm.memory.id);
    totalTokens += tokens;
  }

  // Phase 5: Edge expansion (use remaining budget)
  const remainingBudget = config.tokenBudget - totalTokens;
  if (remainingBudget > 100 && selectedIds.length > 0) {
    const expanded = await expandWithEdges(client, selectedIds, remainingBudget);
    for (const expandedMemory of expanded) {
      const text = formatMemoryForContext({
        memory: expandedMemory,
        score: 0,
        scope: "persona",
        edgeExpansion: true,
      });
      const tokens = estimateTokens(text);
      if (totalTokens + tokens > config.tokenBudget) break;
      selected.push({
        memory: expandedMemory,
        score: 0,
        scope: "persona",
        edgeExpansion: true,
      });
      totalTokens += tokens;
    }
  }

  // Phase 6: Reinforce retrieved memories (async, non-blocking)
  reinforceRetrievedMemories(client, selectedIds);

  // Phase 7: Format for context
  return selected.map(rm => formatMemoryForContext(rm)).join("\n");
}
```

### Instance-Scoped Memory Search 

Searches across all personas in the same Instance, excluding the current persona (to avoid duplicating persona-scoped results):

```sql
CREATE OR REPLACE FUNCTION search_instance_memories(
  p_instance_id UUID,
  p_exclude_persona_id UUID,
  p_embedding vector(1536),
  p_match_threshold FLOAT DEFAULT 0.6,
  p_match_count INT DEFAULT 10
)
RETURNS TABLE (
  id UUID,
  persona_id UUID,
  type TEXT,
  content TEXT,
  confidence TEXT,
  layer TEXT,
  tags TEXT[],
  created_at TIMESTAMPTZ,
  similarity FLOAT
)
LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY
  SELECT
    pm.id,
    pm.persona_id,
    pm.type,
    pm.content,
    pm.confidence,
    pm.layer,
    pm.tags,
    pm.created_at,
    1 - (pm.embedding <=> p_embedding) AS similarity
  FROM persona_memories pm
  JOIN personas p ON p.id = pm.persona_id
  JOIN instance_personas ip ON ip.persona_id = p.id
  WHERE ip.instance_id = p_instance_id
    AND pm.persona_id != p_exclude_persona_id
    AND pm.active = true
    AND pm.embedding IS NOT NULL
    AND 1 - (pm.embedding <=> p_embedding) > p_match_threshold
  ORDER BY pm.embedding <=> p_embedding
  LIMIT p_match_count;
END;
$$;
```

### Retrieval Scoring 

The retrieval score combines semantic similarity, recency, confidence, layer status, and scope priority:

```ts
function computeRetrievalScore(
  similarity: number,
  createdAt: string,
  confidence: string,
  layer: string,
  scope: "persona" | "instance" | "global"
): number {
  // Similarity weight (0-1): primary signal
  const similarityWeight = 0.45;

  // Recency weight (0-1): newer memories slightly preferred
  const ageHours = (Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60);
  const recency = Math.max(0, 1 - ageHours / (24 * 180)); // Decays over 180 days
  const recencyWeight = 0.15;

  // Confidence weight (0-1)
  const confidenceValue = confidence === "strong" ? 1.0 : confidence === "medium" ? 0.7 : 0.4;
  const confidenceWeight = 0.15;

  // Layer weight: closed memories get a boost
  const layerBoost = layer === "closed" ? 0.1 : 0;

  // Scope weight: persona > instance > global
  const scopeBoost = scope === "persona" ? 0.15 : scope === "instance" ? 0.08 : 0;

  return (
    similarity * similarityWeight +
    recency * recencyWeight +
    confidenceValue * confidenceWeight +
    layerBoost +
    scopeBoost
  );
}
```

### Memory Formatting for Context 

Memories are formatted differently based on their scope and type:

```ts
function formatMemoryForContext(rm: RankedMemory): string {
  const { memory, scope, edgeExpansion } = rm;
  const typeLabel = memory.type.charAt(0).toUpperCase() + memory.type.slice(1);
  const layerIndicator = memory.layer === "closed" ? "★" : "";
  const scopePrefix = scope === "instance" ? "[Cross-persona] " : "";
  const edgePrefix = edgeExpansion ? "[Related] " : "";

  return `${edgePrefix}${scopePrefix}[${typeLabel}${layerIndicator}] ${memory.content} (${memory.confidence})`;
}
```

**Example assembled memories block:**

```
[Decision★] We chose the asymmetric layout for the hero section (strong)
[Fact★] Client's brand color is #1a1a2e (strong)
[Preference] Bob prefers concise responses (medium)
[Related] [Procedure★] Hero section uses 60/40 split with image on right (strong)
[Cross-persona] [Fact] API endpoint is /api/v2/layouts (medium)
[Skill] Can convert Figma designs to React components (medium)
```

### Background Reinforcement 

When memories are retrieved and used in context, their confidence is silently reinforced:

```ts
async function reinforceRetrievedMemories(
  client: SupabaseClient,
  memoryIds: string[]
) {
  // Fire-and-forget — do not await in the hot path
  for (const id of memoryIds) {
    const { data } = await client
      .from("persona_memories")
      .select("confidence, layer")
      .eq("id", id)
      .single();

    if (!data) continue;

    // Only reinforce open-layer memories
    if (data.layer === "closed") continue;

    const promotionMap: Record<string, string> = {
      weak: "medium",
      medium: "strong",
    };

    const newConfidence = promotionMap[data.confidence];
    if (newConfidence) {
      await client
        .from("persona_memories")
        .update({
          confidence: newConfidence,
          updated_at: new Date().toISOString(),
        })
        .eq("id", id);
    }
  }
}
```

---

## 15.9 Memory Statistics & Analytics Integration 

Part 12 (Insights & Analytics) displays memory health metrics. This section defines the data source for those metrics.

### Statistics RPC 

```sql
CREATE OR REPLACE FUNCTION cognigraph_stats(p_user_id UUID)
RETURNS TABLE (
  total_memories BIGINT,
  active_memories BIGINT,
  open_layer BIGINT,
  closed_layer BIGINT,
  by_type JSONB,
  conflict_count BIGINT,
  stale_count BIGINT,
  avg_confidence NUMERIC,
  total_edges BIGINT,
  total_checkpoints BIGINT
)
LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY
  WITH user_personas AS (
    SELECT id FROM personas WHERE user_id = p_user_id
  ),
  mem_stats AS (
    SELECT
      COUNT(*) AS total,
      COUNT(*) FILTER (WHERE active = true) AS active,
      COUNT(*) FILTER (WHERE active = true AND layer = 'open') AS open_count,
      COUNT(*) FILTER (WHERE active = true AND layer = 'closed') AS closed_count,
      jsonb_build_object(
        'decision', COUNT(*) FILTER (WHERE active = true AND type = 'decision'),
        'fact', COUNT(*) FILTER (WHERE active = true AND type = 'fact'),
        'preference', COUNT(*) FILTER (WHERE active = true AND type = 'preference'),
        'skill', COUNT(*) FILTER (WHERE active = true AND type = 'skill'),
        'procedure', COUNT(*) FILTER (WHERE active = true AND type = 'procedure')
      ) AS type_counts,
      COUNT(*) FILTER (WHERE active = true AND tags @> ARRAY['conflict']) AS conflicts,
      COUNT(*) FILTER (WHERE active = true AND tags @> ARRAY['stale']) AS stale,
      AVG(
        CASE confidence
          WHEN 'strong' THEN 1.0
          WHEN 'medium' THEN 0.66
          WHEN 'weak' THEN 0.33
          ELSE 0
        END
      ) FILTER (WHERE active = true) AS avg_conf
    FROM persona_memories
    WHERE persona_id IN (SELECT id FROM user_personas)
  ),
  edge_stats AS (
    SELECT COUNT(*) AS edge_count
    FROM memory_edges
    WHERE from_node_id IN (
      SELECT id FROM persona_memories WHERE persona_id IN (SELECT id FROM user_personas)
    )
  ),
  checkpoint_stats AS (
    SELECT COUNT(*) AS cp_count
    FROM memory_checkpoints
    WHERE chat_id IN (SELECT id FROM chats WHERE user_id = p_user_id)
  )
  SELECT
    ms.total,
    ms.active,
    ms.open_count,
    ms.closed_count,
    ms.type_counts,
    ms.conflicts,
    ms.stale,
    ROUND(COALESCE(ms.avg_conf, 0), 2),
    es.edge_count,
    cs.cp_count
  FROM mem_stats ms, edge_stats es, checkpoint_stats cs;
END;
$$;
```

### TypeScript Interface for Stats 

```ts
interface CogniGraphStats {
  total_memories: number;
  active_memories: number;
  open_layer: number;
  closed_layer: number;
  by_type: {
    decision: number;
    fact: number;
    preference: number;
    skill: number;
    procedure: number;
  };
  conflict_count: number;
  stale_count: number;
  avg_confidence: number;
  total_edges: number;
  total_checkpoints: number;
}
```

---

## 15.10 Graph Visualization (Future / v2) 

The Memory tab (Part 9\) currently shows memories as a flat list. A future enhancement can render the CogniGraph as an interactive graph visualization. This section documents the data shape for that visualization:

```ts
interface GraphVisualization {
  nodes: {
    id: string;
    label: string;         // Truncated content
    type: string;          // Memory type
    layer: string;         // open | closed
    confidence: string;
    active: boolean;
    x?: number;
    y?: number;
  }[];
  edges: {
    source: string;
    target: string;
    relationship: string;
    strength: number;
  }[];
}

async function buildGraphForPersona(
  client: SupabaseClient,
  personaId: string
): Promise<GraphVisualization> {
  const { data: memories } = await client
    .from("persona_memories")
    .select("id, type, content, layer, confidence, active")
    .eq("persona_id", personaId)
    .eq("active", true)
    .order("created_at", { ascending: false })
    .limit(100); // Cap for performance

  const memoryIds = (memories ?? []).map(m => m.id);

  const { data: edges } = await client
    .from("memory_edges")
    .select("from_node_id, to_node_id, relationship, strength")
    .or(
      memoryIds.map(id => `from_node_id.eq.${id}`).join(",") + "," +
      memoryIds.map(id => `to_node_id.eq.${id}`).join(",")
    );

  return {
    nodes: (memories ?? []).map(m => ({
      id: m.id,
      label: m.content.slice(0, 60) + (m.content.length > 60 ? "..." : ""),
      type: m.type,
      layer: m.layer,
      confidence: m.confidence,
      active: m.active,
    })),
    edges: (edges ?? []).map(e => ({
      source: e.from_node_id,
      target: e.to_node_id,
      relationship: e.relationship,
      strength: e.strength,
    })),
  };
}
```

The graph would be rendered using D3.js force-directed layout (available in the frontend stack per Part 1). Nodes colored by type, sized by confidence, with edges styled by relationship type (solid for supports, dashed for contradicts, dotted for related). This is scoped for v2 and not included in the v1 build.

---

## 15.11 Performance Considerations 

### Embedding Index Tuning 

The `ivfflat` index on `persona_memories.embedding` requires tuning as the dataset grows:

```sql
-- Initial: small dataset (<10,000 memories per user)
CREATE INDEX idx_memories_embedding ON public.persona_memories
  USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- After growth: re-index with more lists (run periodically)
-- General rule: lists = sqrt(total_rows)
-- For 100,000 memories: lists = 316
REINDEX INDEX idx_memories_embedding;
```

For very large memory sets (\>100,000 memories per persona), consider switching to `hnsw` index which offers better recall at higher memory cost:

```sql
CREATE INDEX idx_memories_embedding_hnsw ON public.persona_memories
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
```

### Retrieval Latency Targets 

| Operation | Target Latency | Strategy |
| :---- | :---- | :---- |
| Semantic search (top-20) | \< 50ms | ivfflat/hnsw index, pre-filtered by persona\_id |
| Edge expansion (1 hop) | \< 30ms | Index on from\_node\_id, limit to 3 edges per seed |
| Checkpoint loading | \< 20ms | Index on chat\_id \+ sequence\_number |
| Full retrieval pipeline | \< 150ms | Parallel scope queries, cached embeddings |

### Memory Limits 

| Limit | Value | Purpose |
| :---- | :---- | :---- |
| Memories per persona | 5,000 (soft) | Performance; older inactive memories purged |
| Edges per memory | 20 | Prevent dense graph clusters |
| Checkpoints per chat | 200 | Long conversations; oldest checkpoints merged |
| Embedding dimension | 1536 | OpenAI text-embedding-3-small |

When a persona approaches the 5,000 memory soft limit, `cipher-cleanup` becomes more aggressive: weak open-layer memories are deactivated after 14 days instead of 30, and stale memories are deactivated after 60 days instead of 90\.

---

*End of Part 15\. Proceed to Part 16: Multi-Model Routing.*

# PART 16: MULTI-MODEL ROUTING 

---

Multi-model routing is the subsystem that connects aiConnected to the underlying AI models. It abstracts away the differences between providers (Anthropic, OpenAI, Google, DeepSeek), manages API keys (platform-owned and user-owned BYOK), enforces tier-based access, tracks token consumption for billing and analytics, handles streaming delivery, and executes the fallback chain when models fail.

Part 13 defined the `model_role_assignments` table and the `AVAILABLE_MODELS` client-side registry. Part 14 defined the `selectModel()`, `resolveApiKey()`, and `streamResponse()` functions within the Cipher pipeline. Part 12 defined the `analytics_model_usage` RPC for cost reporting. This part completes the system by defining the server-side model registry, the provider abstraction layer, the full token accounting pipeline, per-model rate limiting, direct provider integration, and the streaming protocol specification.

---

## 16.1 Model Registry 

### Database Table: model\_registry 

The model registry is a platform-managed table that defines every model available in aiConnected. Unlike `model_role_assignments` (which is user-specific), the model registry is shared across all users and managed by the platform team. It is the single source of truth for model capabilities, pricing, context limits, and availability.

```sql
CREATE TABLE public.model_registry (
  id TEXT PRIMARY KEY,                           -- Model API string (e.g. "claude-sonnet-4-5-20250929")
  display_name TEXT NOT NULL,                    -- Human-readable name
  provider TEXT NOT NULL CHECK (provider IN (
    'anthropic', 'openai', 'google', 'deepseek', 'openrouter'
  )),
  provider_model_id TEXT NOT NULL,               -- ID used when calling the provider's API directly
  openrouter_model_id TEXT NOT NULL,             -- ID used when calling via OpenRouter
  tier_required TEXT NOT NULL DEFAULT 'free' CHECK (tier_required IN (
    'free', 'plus', 'premium', 'pro'
  )),
  byok_only BOOLEAN NOT NULL DEFAULT false,      -- Only available with user's own API key
  capabilities TEXT[] NOT NULL DEFAULT '{}',      -- Roles this model can serve: {"research","writing","coding","creative","planning","reasoning","quick"}
  context_window INTEGER NOT NULL DEFAULT 128000, -- Maximum tokens (input + output)
  max_output_tokens INTEGER NOT NULL DEFAULT 4096,-- Maximum response tokens
  supports_streaming BOOLEAN NOT NULL DEFAULT true,
  supports_images BOOLEAN NOT NULL DEFAULT false, -- Can process image inputs
  supports_tools BOOLEAN NOT NULL DEFAULT false,  -- Function calling / tool use
  pricing_prompt NUMERIC(8,4) NOT NULL DEFAULT 0, -- Cost per 1M input tokens (USD)
  pricing_completion NUMERIC(8,4) NOT NULL DEFAULT 0, -- Cost per 1M output tokens (USD)
  status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'deprecated', 'disabled')),
  sort_order INTEGER NOT NULL DEFAULT 100,        -- Display ordering within provider
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- No RLS: this is a platform-managed read-only table for all users
ALTER TABLE public.model_registry ENABLE ROW LEVEL SECURITY;

CREATE POLICY "All authenticated users can read"
  ON public.model_registry FOR SELECT
  USING (auth.role() = 'authenticated');

CREATE POLICY "Service role manages registry"
  ON public.model_registry FOR ALL
  USING (auth.role() = 'service_role');

CREATE INDEX idx_model_registry_provider ON public.model_registry(provider, sort_order);
CREATE INDEX idx_model_registry_status ON public.model_registry(status) WHERE status = 'active';

CREATE TRIGGER set_model_registry_updated_at
  BEFORE UPDATE ON public.model_registry
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### Seed Data 

```sql
INSERT INTO public.model_registry
  (id, display_name, provider, provider_model_id, openrouter_model_id, tier_required, byok_only, capabilities, context_window, max_output_tokens, supports_streaming, supports_images, supports_tools, pricing_prompt, pricing_completion, sort_order)
VALUES
  -- Anthropic
  ('claude-haiku-4-5-20251001', 'Claude Haiku 4.5', 'anthropic',
   'claude-haiku-4-5-20251001', 'anthropic/claude-haiku-4-5-20251001',
   'free', false,
   '{"writing","quick","coding"}',
   200000, 4096, true, true, true, 0.80, 4.00, 10),

  ('claude-sonnet-4-5-20250929', 'Claude Sonnet 4.5', 'anthropic',
   'claude-sonnet-4-5-20250929', 'anthropic/claude-sonnet-4-5-20250929',
   'plus', false,
   '{"research","writing","coding","planning","reasoning","quick"}',
   200000, 8192, true, true, true, 3.00, 15.00, 20),

  ('claude-opus-4-5-20250301', 'Claude Opus 4.5', 'anthropic',
   'claude-opus-4-5-20250301', 'anthropic/claude-opus-4-5-20250301',
   'premium', false,
   '{"research","planning","reasoning"}',
   200000, 8192, true, true, true, 15.00, 75.00, 30),

  -- OpenAI
  ('gpt-4o', 'GPT-4o', 'openai',
   'gpt-4o', 'openai/gpt-4o',
   'plus', false,
   '{"research","writing","coding","planning","reasoning","quick"}',
   128000, 4096, true, true, true, 2.50, 10.00, 40),

  ('gpt-4o-mini', 'GPT-4o Mini', 'openai',
   'gpt-4o-mini', 'openai/gpt-4o-mini',
   'free', false,
   '{"writing","quick","coding"}',
   128000, 4096, true, true, true, 0.15, 0.60, 50),

  -- Google
  ('gemini-2.5-pro', 'Gemini 2.5 Pro', 'google',
   'gemini-2.5-pro', 'google/gemini-2.5-pro-preview',
   'plus', true,
   '{"research","writing","coding","reasoning"}',
   1000000, 8192, true, true, true, 1.25, 5.00, 60),

  ('gemini-2.5-flash', 'Gemini 2.5 Flash', 'google',
   'gemini-2.5-flash', 'google/gemini-2.5-flash-preview',
   'free', true,
   '{"writing","quick","coding"}',
   1000000, 8192, true, true, true, 0.15, 0.60, 70),

  -- DeepSeek
  ('deepseek-r1', 'DeepSeek R1', 'deepseek',
   'deepseek-reasoner', 'deepseek/deepseek-r1',
   'plus', true,
   '{"reasoning","coding","research"}',
   128000, 8192, true, false, false, 0.55, 2.19, 80),

  -- OpenAI Image
  ('dall-e-3', 'DALL-E 3', 'openai',
   'dall-e-3', 'openai/dall-e-3',
   'plus', true,
   '{"creative"}',
   4096, 0, false, false, false, 0.04, 0.08, 90);
```

### TypeScript Interface 

```ts
// src/types/model.ts

interface ModelRegistryEntry {
  id: string;
  display_name: string;
  provider: "anthropic" | "openai" | "google" | "deepseek" | "openrouter";
  provider_model_id: string;
  openrouter_model_id: string;
  tier_required: "free" | "plus" | "premium" | "pro";
  byok_only: boolean;
  capabilities: string[];
  context_window: number;
  max_output_tokens: number;
  supports_streaming: boolean;
  supports_images: boolean;
  supports_tools: boolean;
  pricing_prompt: number;
  pricing_completion: number;
  status: "active" | "deprecated" | "disabled";
  sort_order: number;
  created_at: string;
  updated_at: string;
}
```

### Model Availability Resolution 

A model is available to a user if all of the following are true:

1. `status = 'active'` in the registry  
2. The user's tier meets or exceeds `tier_required`  
3. If `byok_only = true`, the user has an active API key for the model's provider (or an active OpenRouter key)

```ts
function isModelAvailable(
  model: ModelRegistryEntry,
  userTier: string,
  activeKeyProviders: string[]
): boolean {
  if (model.status !== "active") return false;

  const TIER_RANK: Record<string, number> = {
    free: 0, plus: 1, premium: 2, pro: 3,
  };

  const userRank = TIER_RANK[userTier] ?? 0;
  const requiredRank = TIER_RANK[model.tier_required] ?? 0;

  if (userRank < requiredRank) return false;

  if (model.byok_only) {
    const hasDirectKey = activeKeyProviders.includes(model.provider);
    const hasOpenRouterKey = activeKeyProviders.includes("openrouter");
    if (!hasDirectKey && !hasOpenRouterKey) return false;
  }

  return true;
}
```

### Client-Side Model Query 

```ts
const { data: models } = useQuery({
  queryKey: ["model-registry"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("model_registry")
      .select("*")
      .eq("status", "active")
      .order("sort_order", { ascending: true });
    return data ?? [];
  },
  staleTime: 1000 * 60 * 60, // Cache for 1 hour — registry changes rarely
});

const { data: apiKeys } = useQuery({
  queryKey: ["api-keys"],
  queryFn: async () => {
    const supabase = createClient();
    const { data } = await supabase
      .from("api_keys")
      .select("provider, status")
      .eq("status", "active");
    return data ?? [];
  },
});

const activeKeyProviders = (apiKeys ?? []).map(k => k.provider);

const availableModels = (models ?? []).filter(m =>
  isModelAvailable(m, profile.tier, activeKeyProviders)
);
```

---

## 16.2 Provider Abstraction Layer 

The provider abstraction layer allows Cipher to call any model through a uniform interface, regardless of whether the request goes through OpenRouter or directly to a provider's API. This is critical for BYOK users who may have direct provider keys.

### Routing Strategy 

```
┌──────────────────────────────────┐
│         Model Call Request        │
│  model: "claude-sonnet-4-5-..."  │
│  apiKey: (resolved)              │
└───────────────┬──────────────────┘
                │
                ▼
     ┌─────────────────────┐
     │ Determine Route     │
     │                     │
     │ Has direct key for  │──── Yes ──▶ Direct API Call
     │ this provider?      │            (provider_model_id)
     │                     │
     └─────────┬───────────┘
               │ No
               ▼
     ┌─────────────────────┐
     │ Has OpenRouter key?  │──── Yes ──▶ OpenRouter API Call
     │                      │            (openrouter_model_id)
     └─────────┬────────────┘
               │ No
               ▼
     ┌─────────────────────┐
     │ Use platform key     │──────────▶ OpenRouter API Call
     │ (Deno env var)       │            (openrouter_model_id)
     └──────────────────────┘
```

### Provider Interface 

```ts
interface ProviderCallOptions {
  modelId: string;          // Registry ID (e.g. "claude-sonnet-4-5-20250929")
  messages: { role: string; content: string }[];
  stream: boolean;
  maxTokens: number;
  temperature?: number;
}

interface ProviderCallResult {
  content: string;
  usage: {
    prompt_tokens: number;
    completion_tokens: number;
    total_tokens: number;
  };
  model_used: string;
  provider_used: string;
  latency_ms: number;
}

interface StreamChunk {
  type: "token" | "done" | "error";
  content?: string;
  usage?: ProviderCallResult["usage"];
  error?: string;
}
```

### Provider Router 

```ts
async function routeModelCall(
  client: SupabaseClient,
  userId: string,
  options: ProviderCallOptions,
  registry: ModelRegistryEntry
): Promise<{
  apiKey: string;
  baseURL: string;
  modelString: string;
  providerUsed: string;
}> {
  // Load user's active API keys
  const { data: userKeys } = await client
    .from("api_keys")
    .select("provider, encrypted_key")
    .eq("user_id", userId)
    .eq("status", "active");

  const keyMap = new Map<string, string>();
  for (const key of userKeys ?? []) {
    keyMap.set(key.provider, key.encrypted_key);
  }

  // Priority 1: Direct provider key
  if (keyMap.has(registry.provider)) {
    const decryptedKey = await decryptApiKey(keyMap.get(registry.provider)!);
    const providerConfig = getProviderConfig(registry.provider);
    return {
      apiKey: decryptedKey,
      baseURL: providerConfig.baseURL,
      modelString: registry.provider_model_id,
      providerUsed: registry.provider,
    };
  }

  // Priority 2: OpenRouter user key
  if (keyMap.has("openrouter")) {
    const decryptedKey = await decryptApiKey(keyMap.get("openrouter")!);
    return {
      apiKey: decryptedKey,
      baseURL: "https://openrouter.ai/api/v1",
      modelString: registry.openrouter_model_id,
      providerUsed: "openrouter",
    };
  }

  // Priority 3: Platform OpenRouter key
  return {
    apiKey: Deno.env.get("OPENROUTER_API_KEY")!,
    baseURL: "https://openrouter.ai/api/v1",
    modelString: registry.openrouter_model_id,
    providerUsed: "platform",
  };
}
```

### Provider Configurations 

```ts
interface ProviderConfig {
  baseURL: string;
  authHeader: (key: string) => Record<string, string>;
  extraHeaders?: Record<string, string>;
  supportsOpenAIFormat: boolean;
}

function getProviderConfig(provider: string): ProviderConfig {
  switch (provider) {
    case "anthropic":
      return {
        baseURL: "https://api.anthropic.com/v1",
        authHeader: (key) => ({
          "x-api-key": key,
          "anthropic-version": "2023-06-01",
        }),
        supportsOpenAIFormat: false,
      };

    case "openai":
      return {
        baseURL: "https://api.openai.com/v1",
        authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
        supportsOpenAIFormat: true,
      };

    case "google":
      return {
        baseURL: "https://generativelanguage.googleapis.com/v1beta",
        authHeader: (key) => ({ "x-goog-api-key": key }),
        supportsOpenAIFormat: false,
      };

    case "deepseek":
      return {
        baseURL: "https://api.deepseek.com/v1",
        authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
        supportsOpenAIFormat: true,
      };

    case "openrouter":
    default:
      return {
        baseURL: "https://openrouter.ai/api/v1",
        authHeader: (key) => ({ Authorization: `Bearer ${key}` }),
        extraHeaders: {
          "HTTP-Referer": "https://aiconnected.app",
          "X-Title": "aiConnected",
        },
        supportsOpenAIFormat: true,
      };
  }
}
```

### Unified Model Call 

The unified call function handles both OpenAI-compatible APIs (OpenAI, DeepSeek, OpenRouter) and non-compatible APIs (Anthropic, Google) through format adapters.

```ts
import OpenAI from "https://esm.sh/openai@4";

async function callModel(
  route: { apiKey: string; baseURL: string; modelString: string; providerUsed: string },
  options: ProviderCallOptions,
  registry: ModelRegistryEntry
): Promise<ProviderCallResult> {
  const startTime = Date.now();
  const providerConfig = getProviderConfig(route.providerUsed === "platform" ? "openrouter" : route.providerUsed);

  // Use OpenAI SDK for all OpenAI-compatible providers
  if (providerConfig.supportsOpenAIFormat || route.providerUsed === "platform") {
    const client = new OpenAI({
      apiKey: route.apiKey,
      baseURL: route.baseURL,
      defaultHeaders: providerConfig.extraHeaders,
    });

    const response = await client.chat.completions.create({
      model: route.modelString,
      messages: options.messages,
      max_tokens: Math.min(options.maxTokens, registry.max_output_tokens),
      temperature: options.temperature ?? 0.7,
      stream: false,
    });

    return {
      content: response.choices[0]?.message?.content ?? "",
      usage: {
        prompt_tokens: response.usage?.prompt_tokens ?? 0,
        completion_tokens: response.usage?.completion_tokens ?? 0,
        total_tokens: response.usage?.total_tokens ?? 0,
      },
      model_used: route.modelString,
      provider_used: route.providerUsed,
      latency_ms: Date.now() - startTime,
    };
  }

  // Anthropic direct API (non-OpenAI format)
  if (route.providerUsed === "anthropic") {
    return await callAnthropicDirect(route, options, registry, startTime);
  }

  // Google direct API (non-OpenAI format)
  if (route.providerUsed === "google") {
    return await callGoogleDirect(route, options, registry, startTime);
  }

  throw new Error(`Unsupported provider: ${route.providerUsed}`);
}
```

### Anthropic Direct API Adapter 

```ts
async function callAnthropicDirect(
  route: { apiKey: string; baseURL: string; modelString: string; providerUsed: string },
  options: ProviderCallOptions,
  registry: ModelRegistryEntry,
  startTime: number
): Promise<ProviderCallResult> {
  // Convert OpenAI-format messages to Anthropic format
  const systemMessage = options.messages.find(m => m.role === "system");
  const conversationMessages = options.messages
    .filter(m => m.role !== "system")
    .map(m => ({ role: m.role as "user" | "assistant", content: m.content }));

  const body: any = {
    model: route.modelString,
    max_tokens: Math.min(options.maxTokens, registry.max_output_tokens),
    messages: conversationMessages,
  };

  if (systemMessage) {
    body.system = systemMessage.content;
  }

  const response = await fetch(`${route.baseURL}/messages`, {
    method: "POST",
    headers: {
      "x-api-key": route.apiKey,
      "anthropic-version": "2023-06-01",
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw { status: response.status, message: errorBody };
  }

  const data = await response.json();

  return {
    content: data.content?.[0]?.text ?? "",
    usage: {
      prompt_tokens: data.usage?.input_tokens ?? 0,
      completion_tokens: data.usage?.output_tokens ?? 0,
      total_tokens: (data.usage?.input_tokens ?? 0) + (data.usage?.output_tokens ?? 0),
    },
    model_used: route.modelString,
    provider_used: route.providerUsed,
    latency_ms: Date.now() - startTime,
  };
}
```

### Google Direct API Adapter 

```ts
async function callGoogleDirect(
  route: { apiKey: string; baseURL: string; modelString: string; providerUsed: string },
  options: ProviderCallOptions,
  registry: ModelRegistryEntry,
  startTime: number
): Promise<ProviderCallResult> {
  // Convert to Google Generative AI format
  const systemInstruction = options.messages.find(m => m.role === "system");
  const contents = options.messages
    .filter(m => m.role !== "system")
    .map(m => ({
      role: m.role === "assistant" ? "model" : "user",
      parts: [{ text: m.content }],
    }));

  const body: any = {
    contents,
    generationConfig: {
      maxOutputTokens: Math.min(options.maxTokens, registry.max_output_tokens),
      temperature: options.temperature ?? 0.7,
    },
  };

  if (systemInstruction) {
    body.systemInstruction = { parts: [{ text: systemInstruction.content }] };
  }

  const url = `${route.baseURL}/models/${route.modelString}:generateContent?key=${route.apiKey}`;

  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw { status: response.status, message: errorBody };
  }

  const data = await response.json();
  const textContent = data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
  const usageMetadata = data.usageMetadata ?? {};

  return {
    content: textContent,
    usage: {
      prompt_tokens: usageMetadata.promptTokenCount ?? 0,
      completion_tokens: usageMetadata.candidatesTokenCount ?? 0,
      total_tokens: usageMetadata.totalTokenCount ?? 0,
    },
    model_used: route.modelString,
    provider_used: route.providerUsed,
    latency_ms: Date.now() - startTime,
  };
}
```

---

## 16.3 Streaming Protocol 

Streaming delivers tokens to the client in real-time as the model generates them. aiConnected uses Server-Sent Events (SSE) as the streaming transport.

### SSE Event Types 

| Event Type | Payload | When |
| :---- | :---- | :---- |
| `token` | `{ content: string }` | Each chunk of generated text |
| `done` | `{ message_id, model_used, routing_reason, routing_note, token_count, latency_ms, prompt_tokens, completion_tokens, estimated_cost }` | Stream complete |
| `error` | `{ message: string, code: number, retryable: boolean }` | Error during generation |
| `status` | `{ status: "routing" | "generating" | "saving" }` | Progress indicator |

### Streaming Call with Provider Routing 

The unified streaming function extends `callModel` to support streaming across providers:

```ts
async function callModelStreaming(
  route: { apiKey: string; baseURL: string; modelString: string; providerUsed: string },
  options: ProviderCallOptions,
  registry: ModelRegistryEntry,
  onChunk: (chunk: StreamChunk) => void
): Promise<ProviderCallResult> {
  const startTime = Date.now();
  const providerConfig = getProviderConfig(
    route.providerUsed === "platform" ? "openrouter" : route.providerUsed
  );

  if (providerConfig.supportsOpenAIFormat || route.providerUsed === "platform") {
    return streamOpenAICompatible(route, options, registry, onChunk, startTime);
  }

  if (route.providerUsed === "anthropic") {
    return streamAnthropicDirect(route, options, registry, onChunk, startTime);
  }

  if (route.providerUsed === "google") {
    // Google Gemini doesn't support SSE in the same way
    // Fall back to non-streaming call, emit all content as a single token
    const result = await callGoogleDirect(route, options, registry, startTime);
    onChunk({ type: "token", content: result.content });
    onChunk({ type: "done", usage: result.usage });
    return result;
  }

  throw new Error(`Streaming not supported for provider: ${route.providerUsed}`);
}
```

### OpenAI-Compatible Streaming 

```ts
async function streamOpenAICompatible(
  route: { apiKey: string; baseURL: string; modelString: string; providerUsed: string },
  options: ProviderCallOptions,
  registry: ModelRegistryEntry,
  onChunk: (chunk: StreamChunk) => void,
  startTime: number
): Promise<ProviderCallResult> {
  const client = new OpenAI({
    apiKey: route.apiKey,
    baseURL: route.baseURL,
    defaultHeaders: getProviderConfig(
      route.providerUsed === "platform" ? "openrouter" : route.providerUsed
    ).extraHeaders,
  });

  let fullContent = "";
  let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };

  const stream = await client.chat.completions.create({
    model: route.modelString,
    messages: options.messages,
    max_tokens: Math.min(options.maxTokens, registry.max_output_tokens),
    temperature: options.temperature ?? 0.7,
    stream: true,
    stream_options: { include_usage: true },
  });

  for await (const chunk of stream) {
    const content = chunk.choices?.[0]?.delta?.content;
    if (content) {
      fullContent += content;
      onChunk({ type: "token", content });
    }

    if (chunk.usage) {
      usage = {
        prompt_tokens: chunk.usage.prompt_tokens ?? 0,
        completion_tokens: chunk.usage.completion_tokens ?? 0,
        total_tokens: chunk.usage.total_tokens ?? 0,
      };
    }
  }

  // If usage wasn't reported, estimate from content
  if (usage.total_tokens === 0) {
    usage = {
      prompt_tokens: estimateTokens(options.messages.map(m => m.content).join(" ")),
      completion_tokens: estimateTokens(fullContent),
      total_tokens: 0,
    };
    usage.total_tokens = usage.prompt_tokens + usage.completion_tokens;
  }

  return {
    content: fullContent,
    usage,
    model_used: route.modelString,
    provider_used: route.providerUsed,
    latency_ms: Date.now() - startTime,
  };
}
```

### Anthropic Direct Streaming 

```ts
async function streamAnthropicDirect(
  route: { apiKey: string; baseURL: string; modelString: string; providerUsed: string },
  options: ProviderCallOptions,
  registry: ModelRegistryEntry,
  onChunk: (chunk: StreamChunk) => void,
  startTime: number
): Promise<ProviderCallResult> {
  const systemMessage = options.messages.find(m => m.role === "system");
  const conversationMessages = options.messages
    .filter(m => m.role !== "system")
    .map(m => ({ role: m.role as "user" | "assistant", content: m.content }));

  const body: any = {
    model: route.modelString,
    max_tokens: Math.min(options.maxTokens, registry.max_output_tokens),
    messages: conversationMessages,
    stream: true,
  };

  if (systemMessage) body.system = systemMessage.content;

  const response = await fetch(`${route.baseURL}/messages`, {
    method: "POST",
    headers: {
      "x-api-key": route.apiKey,
      "anthropic-version": "2023-06-01",
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw { status: response.status, message: errorBody };
  }

  let fullContent = "";
  let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? "";

    for (const line of lines) {
      if (!line.startsWith("data: ")) continue;
      const data = line.slice(6).trim();
      if (data === "[DONE]") continue;

      try {
        const event = JSON.parse(data);

        if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
          const text = event.delta.text;
          fullContent += text;
          onChunk({ type: "token", content: text });
        }

        if (event.type === "message_delta" && event.usage) {
          usage.completion_tokens = event.usage.output_tokens ?? 0;
        }

        if (event.type === "message_start" && event.message?.usage) {
          usage.prompt_tokens = event.message.usage.input_tokens ?? 0;
        }
      } catch {
        // Skip unparseable lines
      }
    }
  }

  usage.total_tokens = usage.prompt_tokens + usage.completion_tokens;

  return {
    content: fullContent,
    usage,
    model_used: route.modelString,
    provider_used: route.providerUsed,
    latency_ms: Date.now() - startTime,
  };
}
```

### Client-Side SSE Consumer 

```ts
// src/hooks/useStreamingResponse.ts

function useStreamingResponse(chatId: string) {
  const [streamingContent, setStreamingContent] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);
  const abortControllerRef = useRef<AbortController | null>(null);

  const startStream = useCallback(async (messageContent: string, options: SendOptions) => {
    setIsStreaming(true);
    setStreamingContent("");

    const abortController = new AbortController();
    abortControllerRef.current = abortController;

    try {
      const response = await fetch(
        `${SUPABASE_URL}/functions/v1/cipher-route`,
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${session.access_token}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            chat_id: chatId,
            content: messageContent,
            directed_persona_id: options.directedPersonaId ?? null,
            model_override: options.modelOverride ?? null,
            attachments: options.attachments ?? [],
          }),
          signal: abortController.signal,
        }
      );

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message ?? "Request failed");
      }

      const reader = response.body!.getReader();
      const decoder = new TextDecoder();
      let buffer = "";

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const events = buffer.split("\n\n");
        buffer = events.pop() ?? "";

        for (const eventBlock of events) {
          if (!eventBlock.startsWith("data: ")) continue;
          const jsonStr = eventBlock.slice(6);

          try {
            const event = JSON.parse(jsonStr);

            switch (event.type) {
              case "token":
                setStreamingContent(prev => prev + event.content);
                break;

              case "done":
                // Response complete — invalidate chat messages query
                queryClient.invalidateQueries({ queryKey: ["messages", chatId] });
                setIsStreaming(false);
                break;

              case "error":
                console.error("Stream error:", event.message);
                setIsStreaming(false);
                break;

              case "status":
                // Could show "Routing...", "Generating...", "Saving..."
                break;
            }
          } catch {
            // Skip unparseable events
          }
        }
      }
    } catch (error: any) {
      if (error.name !== "AbortError") {
        console.error("Stream failed:", error);
      }
    } finally {
      setIsStreaming(false);
      abortControllerRef.current = null;
    }
  }, [chatId]);

  const stopStream = useCallback(() => {
    abortControllerRef.current?.abort();
    setIsStreaming(false);
  }, []);

  return { streamingContent, isStreaming, startStream, stopStream };
}
```

---

## 16.4 Token Accounting 

Every model call generates token usage data that is tracked for three purposes: billing analytics (Part 12), rate limiting, and cost estimation for BYOK users.

### Token Usage Table 

```sql
CREATE TABLE public.token_usage (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  message_id UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  model_id TEXT NOT NULL,                          -- References model_registry.id
  provider_used TEXT NOT NULL,                     -- "anthropic", "openrouter", "platform", etc.
  prompt_tokens INTEGER NOT NULL DEFAULT 0,
  completion_tokens INTEGER NOT NULL DEFAULT 0,
  total_tokens INTEGER NOT NULL DEFAULT 0,
  estimated_cost_usd NUMERIC(10,6) NOT NULL DEFAULT 0,
  is_byok BOOLEAN NOT NULL DEFAULT false,          -- Whether user's own key was used
  latency_ms INTEGER,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.token_usage ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own token usage"
  ON public.token_usage FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Service role insert usage"
  ON public.token_usage FOR INSERT
  USING (auth.role() = 'service_role');

CREATE INDEX idx_token_usage_user_created ON public.token_usage(user_id, created_at DESC);
CREATE INDEX idx_token_usage_model ON public.token_usage(model_id, created_at DESC);
CREATE INDEX idx_token_usage_chat ON public.token_usage(chat_id) WHERE chat_id IS NOT NULL;

-- Partition by month for performance at scale (optional — enable when >1M rows)
-- CREATE TABLE public.token_usage_y2026m01 PARTITION OF public.token_usage
--   FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
```

TypeScript interface:

```ts
interface TokenUsageRecord {
  id: string;
  user_id: string;
  chat_id: string | null;
  message_id: string | null;
  model_id: string;
  provider_used: string;
  prompt_tokens: number;
  completion_tokens: number;
  total_tokens: number;
  estimated_cost_usd: number;
  is_byok: boolean;
  latency_ms: number | null;
  created_at: string;
}
```

### Recording Token Usage 

After every model call (both streaming and non-streaming), Cipher records usage:

```ts
async function recordTokenUsage(
  client: SupabaseClient,
  params: {
    userId: string;
    chatId: string;
    messageId: string;
    modelId: string;
    providerUsed: string;
    usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
    isByok: boolean;
    latencyMs: number;
  }
) {
  // Look up pricing from registry
  const { data: model } = await client
    .from("model_registry")
    .select("pricing_prompt, pricing_completion")
    .eq("id", params.modelId)
    .single();

  const estimatedCost = model
    ? (params.usage.prompt_tokens / 1_000_000) * model.pricing_prompt
      + (params.usage.completion_tokens / 1_000_000) * model.pricing_completion
    : 0;

  await client.from("token_usage").insert({
    user_id: params.userId,
    chat_id: params.chatId,
    message_id: params.messageId,
    model_id: params.modelId,
    provider_used: params.providerUsed,
    prompt_tokens: params.usage.prompt_tokens,
    completion_tokens: params.usage.completion_tokens,
    total_tokens: params.usage.total_tokens,
    estimated_cost_usd: estimatedCost,
    is_byok: params.isByok,
    latency_ms: params.latencyMs,
  });

  // Also store prompt/completion split on the message metadata
  await client
    .from("messages")
    .update({
      metadata: {
        prompt_tokens: params.usage.prompt_tokens,
        completion_tokens: params.usage.completion_tokens,
        estimated_cost_usd: estimatedCost,
        provider_used: params.providerUsed,
      },
    })
    .eq("id", params.messageId);
}
```

### Aggregation RPCs 

These extend the Part 12 analytics with more granular data:

```sql
-- Daily usage by model (for billing dashboard)
CREATE OR REPLACE FUNCTION daily_token_usage(
  p_user_id UUID,
  p_days INTEGER DEFAULT 30
)
RETURNS TABLE (
  usage_date DATE,
  model_id TEXT,
  total_prompt INTEGER,
  total_completion INTEGER,
  total_cost NUMERIC,
  call_count BIGINT
)
LANGUAGE sql STABLE AS $$
  SELECT
    DATE(created_at) AS usage_date,
    tu.model_id,
    COALESCE(SUM(prompt_tokens), 0)::integer AS total_prompt,
    COALESCE(SUM(completion_tokens), 0)::integer AS total_completion,
    COALESCE(SUM(estimated_cost_usd), 0) AS total_cost,
    COUNT(*) AS call_count
  FROM token_usage tu
  WHERE tu.user_id = p_user_id
    AND tu.created_at > now() - (p_days || ' days')::interval
  GROUP BY DATE(created_at), tu.model_id
  ORDER BY usage_date DESC, total_cost DESC;
$$;

-- Cumulative usage for the current billing period
CREATE OR REPLACE FUNCTION billing_period_usage(
  p_user_id UUID,
  p_period_start TIMESTAMPTZ
)
RETURNS TABLE (
  model_id TEXT,
  total_tokens BIGINT,
  total_cost NUMERIC,
  call_count BIGINT,
  is_byok BOOLEAN
)
LANGUAGE sql STABLE AS $$
  SELECT
    tu.model_id,
    SUM(tu.total_tokens) AS total_tokens,
    SUM(tu.estimated_cost_usd) AS total_cost,
    COUNT(*) AS call_count,
    tu.is_byok
  FROM token_usage tu
  WHERE tu.user_id = p_user_id
    AND tu.created_at >= p_period_start
  GROUP BY tu.model_id, tu.is_byok
  ORDER BY total_cost DESC;
$$;
```

---

## 16.5 Rate Limiting 

Rate limiting operates at three levels: daily message limits (Part 14), per-minute burst limits, and per-model quotas.

### Rate Limit Table 

Rate limit state is tracked in Redis-like fashion using a lightweight table with auto-expiring counters:

```sql
CREATE TABLE public.rate_limit_counters (
  id TEXT PRIMARY KEY,                            -- Composite key: "{user_id}:{scope}:{window}"
  count INTEGER NOT NULL DEFAULT 0,
  window_start TIMESTAMPTZ NOT NULL DEFAULT now(),
  window_end TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Auto-cleanup expired windows
CREATE INDEX idx_rate_limit_expiry ON public.rate_limit_counters(window_end);

-- No RLS — only accessed by service role in Edge Functions
```

### Rate Limit Check 

```ts
interface RateLimitConfig {
  scope: string;       // e.g. "messages_per_minute", "model_calls_per_hour"
  limit: number;
  windowMs: number;    // Window duration in milliseconds
}

const RATE_LIMITS: Record<string, RateLimitConfig[]> = {
  free: [
    { scope: "messages_per_minute", limit: 5, windowMs: 60_000 },
    { scope: "messages_per_day", limit: 15, windowMs: 86_400_000 },
    { scope: "concurrent_streams", limit: 1, windowMs: 300_000 },
  ],
  plus: [
    { scope: "messages_per_minute", limit: 10, windowMs: 60_000 },
    { scope: "messages_per_day", limit: 150, windowMs: 86_400_000 },
    { scope: "concurrent_streams", limit: 2, windowMs: 300_000 },
  ],
  premium: [
    { scope: "messages_per_minute", limit: 20, windowMs: 60_000 },
    { scope: "messages_per_day", limit: 1000, windowMs: 86_400_000 },
    { scope: "concurrent_streams", limit: 3, windowMs: 300_000 },
  ],
  pro: [
    { scope: "messages_per_minute", limit: 30, windowMs: 60_000 },
    { scope: "messages_per_day", limit: 5000, windowMs: 86_400_000 },
    { scope: "concurrent_streams", limit: 5, windowMs: 300_000 },
  ],
};

// BYOK users: per-minute limits still apply (prevents abuse), but daily limits are removed
const BYOK_OVERRIDE_LIMITS: RateLimitConfig[] = [
  { scope: "messages_per_minute", limit: 30, windowMs: 60_000 },
  { scope: "concurrent_streams", limit: 5, windowMs: 300_000 },
];

async function checkAllRateLimits(
  client: SupabaseClient,
  userId: string,
  tier: string,
  isByok: boolean
): Promise<{ allowed: boolean; limitHit?: string; retryAfterMs?: number }> {
  const limits = isByok ? BYOK_OVERRIDE_LIMITS : (RATE_LIMITS[tier] ?? RATE_LIMITS.free);

  for (const limit of limits) {
    const result = await checkRateLimit(client, userId, limit);
    if (!result.allowed) {
      return {
        allowed: false,
        limitHit: limit.scope,
        retryAfterMs: result.retryAfterMs,
      };
    }
  }

  return { allowed: true };
}

async function checkRateLimit(
  client: SupabaseClient,
  userId: string,
  config: RateLimitConfig
): Promise<{ allowed: boolean; retryAfterMs?: number }> {
  const now = new Date();
  const windowStart = new Date(now.getTime() - config.windowMs);
  const counterId = `${userId}:${config.scope}`;

  // Upsert counter with window check
  const { data: existing } = await client
    .from("rate_limit_counters")
    .select("count, window_start, window_end")
    .eq("id", counterId)
    .single();

  if (!existing || new Date(existing.window_end) < now) {
    // Window expired or no counter — create new window
    await client.from("rate_limit_counters").upsert({
      id: counterId,
      count: 1,
      window_start: now.toISOString(),
      window_end: new Date(now.getTime() + config.windowMs).toISOString(),
    });
    return { allowed: true };
  }

  if (existing.count >= config.limit) {
    const retryAfterMs = new Date(existing.window_end).getTime() - now.getTime();
    return { allowed: false, retryAfterMs: Math.max(0, retryAfterMs) };
  }

  // Increment counter
  await client
    .from("rate_limit_counters")
    .update({ count: existing.count + 1 })
    .eq("id", counterId);

  return { allowed: true };
}
```

### Rate Limit Cleanup 

```sql
-- Clean up expired rate limit windows (runs hourly via pg_cron)
SELECT cron.schedule(
  'rate-limit-cleanup',
  '30 * * * *',
  $$DELETE FROM public.rate_limit_counters WHERE window_end < now() - interval '1 hour'$$
);
```

### Rate Limit Response 

When a rate limit is hit, the Edge Function returns a structured error with retry information:

```ts
function rateLimitResponse(limitHit: string, retryAfterMs: number): Response {
  const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);

  return new Response(
    JSON.stringify({
      error: "rate_limit",
      limit: limitHit,
      message: limitHit === "messages_per_day"
        ? "You've reached your daily message limit. Upgrade your plan or add your own API key."
        : limitHit === "messages_per_minute"
        ? "You're sending messages too quickly. Please wait a moment."
        : limitHit === "concurrent_streams"
        ? "Maximum concurrent conversations reached. Please wait for your current response to complete."
        : "Rate limit reached.",
      retry_after_seconds: retryAfterSeconds,
    }),
    {
      status: 429,
      headers: {
        "Content-Type": "application/json",
        "Retry-After": String(retryAfterSeconds),
      },
    }
  );
}
```

**Full rate limit summary:**

| Limit | Free | Plus | Premium | Pro | BYOK |
| :---- | :---- | :---- | :---- | :---- | :---- |
| Messages per minute | 5 | 10 | 20 | 30 | 30 |
| Messages per day | 15 | 150 | 1,000 | 5,000 | Unlimited |
| Concurrent streams | 1 | 2 | 3 | 5 | 5 |

---

## 16.6 Fallback Chain 

When a model call fails, Cipher follows a deterministic fallback chain before surfacing an error to the user.

### Fallback Flow 

```
┌─────────────────────────┐
│  Primary Model Call      │
│  (from role assignment)  │
└────────────┬────────────┘
             │
         Success? ──── Yes ──▶ Return response
             │
             No (error)
             │
             ▼
┌─────────────────────────┐
│  Classify Error          │
│  Is it retryable?        │
└────────────┬────────────┘
             │
         Retryable? ──── Yes ──▶ Retry primary (1 attempt, 2s delay)
             │                          │
             No                    Success? ── Yes ──▶ Return
             │                          │
             ▼                         No
┌─────────────────────────┐             │
│  Fallback Model Call     │ ◀──────────┘
│  (from role assignment)  │
└────────────┬────────────┘
             │
         Success? ──── Yes ──▶ Return response (note: "Used fallback model")
             │
             No
             │
             ▼
┌─────────────────────────┐
│  Tier Default Model      │
│  (Haiku for free,        │
│   Sonnet for paid)       │
└────────────┬────────────┘
             │
         Success? ──── Yes ──▶ Return response (note: "Used default model")
             │
             No
             │
             ▼
┌─────────────────────────┐
│  Surface Error to User   │
│  "All models unavailable"│
└──────────────────────────┘
```

### Implementation 

```ts
async function executeWithFallbackChain(
  client: SupabaseClient,
  userId: string,
  options: ProviderCallOptions,
  profile: Profile,
  intentCategory: string,
  onChunk: (chunk: StreamChunk) => void
): Promise<ProviderCallResult & { fallback_used: boolean; fallback_note: string }> {
  // Load role assignment
  const { data: roleAssignment } = await client
    .from("model_role_assignments")
    .select("primary_model, fallback_model")
    .eq("user_id", userId)
    .eq("role", intentCategory)
    .single();

  const primaryModelId = options.modelId ?? roleAssignment?.primary_model;
  const fallbackModelId = roleAssignment?.fallback_model;

  // Load model registry entries
  const { data: models } = await client
    .from("model_registry")
    .select("*")
    .in("id", [primaryModelId, fallbackModelId, getDefaultModelForTier(profile.tier)].filter(Boolean))
    .eq("status", "active");

  const modelMap = new Map((models ?? []).map(m => [m.id, m]));

  // Attempt 1: Primary model
  const primaryRegistry = modelMap.get(primaryModelId);
  if (primaryRegistry) {
    const route = await routeModelCall(client, userId, options, primaryRegistry);

    try {
      const result = await callModelStreaming(route, { ...options, modelId: primaryModelId }, primaryRegistry, onChunk);
      return { ...result, fallback_used: false, fallback_note: "" };
    } catch (primaryError: any) {
      cipherLog("warn", "primary_model_failed", {
        model: primaryModelId,
        error: primaryError.message,
        status: primaryError.status,
      });

      // Retry once if retryable
      if (isRetryableError(primaryError)) {
        await delay(2000);
        try {
          const result = await callModelStreaming(route, { ...options, modelId: primaryModelId }, primaryRegistry, onChunk);
          return { ...result, fallback_used: false, fallback_note: "" };
        } catch {
          // Retry also failed — move to fallback
        }
      }
    }
  }

  // Attempt 2: Configured fallback model
  if (fallbackModelId) {
    const fallbackRegistry = modelMap.get(fallbackModelId);
    if (fallbackRegistry) {
      const route = await routeModelCall(client, userId, options, fallbackRegistry);

      try {
        onChunk({ type: "status", content: "Switching to fallback model..." } as any);
        const result = await callModelStreaming(route, { ...options, modelId: fallbackModelId }, fallbackRegistry, onChunk);
        return {
          ...result,
          fallback_used: true,
          fallback_note: `Primary model (${primaryModelId}) unavailable. Used fallback (${fallbackModelId}).`,
        };
      } catch (fallbackError: any) {
        cipherLog("warn", "fallback_model_failed", {
          model: fallbackModelId,
          error: fallbackError.message,
        });
      }
    }
  }

  // Attempt 3: Tier default model
  const defaultModelId = getDefaultModelForTier(profile.tier);
  if (defaultModelId !== primaryModelId && defaultModelId !== fallbackModelId) {
    const defaultRegistry = modelMap.get(defaultModelId);
    if (defaultRegistry) {
      const route = await routeModelCall(client, userId, options, defaultRegistry);

      try {
        const result = await callModelStreaming(route, { ...options, modelId: defaultModelId }, defaultRegistry, onChunk);
        return {
          ...result,
          fallback_used: true,
          fallback_note: `All configured models unavailable. Used tier default (${defaultModelId}).`,
        };
      } catch {
        // All attempts exhausted
      }
    }
  }

  // All attempts failed
  throw {
    status: 503,
    message: "All models are currently unavailable. Please try again in a few minutes.",
    retryable: true,
  };
}

function isRetryableError(error: any): boolean {
  if (error.status === 429) return true; // Rate limited
  if (error.status === 502 || error.status === 503) return true; // Server unavailable
  if (error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT") return true;
  return false;
}

function delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}
```

### Fallback Notification 

When a fallback model is used, the `done` SSE event includes a `fallback_note` field. The client renders this as a subtle system note below the response:

```ts
// Rendered below the persona message when fallback_note is present
{
  fontSize: 10,
  color: theme.textFaint,
  fontWeight: 300,
  fontStyle: "italic",
  padding: "4px 0 0 34px", // Aligned with message content (past avatar)
}
// Icon: AlertCircle (8px) + fallback_note text
```

---

## 16.7 Model Override (Chat Composer) 

Users can override the model for a single message using the model selector in the chat composer (Part 5). This bypasses Cipher's role-based selection for that specific message.

### Override Priority (from Part 14\) 

Restated here for completeness:

1. **User's explicit model override** (from composer selector) → always wins  
2. **Persona-specific model** (from persona.settings.model) → if set  
3. **Role-based assignment** (from model\_role\_assignments for the detected intent) → default  
4. **Tier default** (Haiku for free, Sonnet for paid) → final fallback

### Model Selector Widget 

The model selector appears in the chat composer when the user has Plus+ tier or BYOK active. It shows a dropdown of available models filtered by the user's access.

**Trigger:** Small chip button in the composer bar, showing the currently active model (abbreviated):

```ts
// Compact model chip in composer
{
  display: "inline-flex",
  alignItems: "center",
  gap: 4,
  padding: "3px 8px",
  borderRadius: 12,
  fontSize: 10,
  fontWeight: 400,
  color: theme.textMuted,
  background: theme.surfaceAlt,
  cursor: "pointer",
  border: `1px solid ${theme.borderSubtle}`,
}
// Cpu icon (9px) + model abbreviation (e.g. "Sonnet 4.5")
```

**Click:** Opens a popover listing available models, grouped by provider:

```ts
// Model selector popover
{
  position: "absolute",
  bottom: "100%",
  left: 0,
  marginBottom: 6,
  width: 260,
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  boxShadow: "0 4px 20px rgba(0,0,0,0.15)",
  padding: "6px 0",
  maxHeight: 320,
  overflowY: "auto",
}
```

**Provider groups:**

```ts
// Provider header
{
  padding: "8px 14px 4px",
  fontSize: 9,
  fontWeight: 500,
  color: theme.textFaint,
  textTransform: "uppercase",
  letterSpacing: "0.06em",
}
```

**Model options:**

```ts
// Each model option
{
  padding: "8px 14px",
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  cursor: isAvailable ? "pointer" : "default",
  opacity: isAvailable ? 1 : 0.4,
}
// Left: model name (12px, weight 400)
// Right: Check icon if currently selected, Lock icon if tier-gated
```

**Selection:** Clicking a model sets `model_override` for the next message. The composer chip updates to show the selected model. After sending, the override resets to null (one-shot).

**"Auto" option:** First item in the list, always present. When selected, Cipher uses role-based routing (default behavior). Displayed as "Auto (Cipher selects)" in 12px, `textMuted`.

---

## 16.8 Token Budget for Background Tasks 

Background Cipher tasks (memory extraction, auto-rename, suggested move, instruction detection) consume tokens independently of the user's conversation. These costs are tracked separately and are always charged to the platform key, never to the user's BYOK key.

### Background Model Configuration 

```ts
const BACKGROUND_MODEL = "claude-haiku-4-5-20251001";
const BACKGROUND_MAX_TOKENS: Record<string, number> = {
  memory_extraction: 1000,
  auto_rename: 50,
  suggested_move: 100,
  instruction_detection: 200,
  checkpoint_summary: 300,
};
```

### Background Token Tracking 

Background tasks record usage with `provider_used = 'platform_background'` to distinguish from user-facing model calls:

```ts
async function recordBackgroundUsage(
  client: SupabaseClient,
  userId: string,
  task: string,
  usage: { prompt_tokens: number; completion_tokens: number }
) {
  const model = BACKGROUND_MODEL;
  const { data: registry } = await client
    .from("model_registry")
    .select("pricing_prompt, pricing_completion")
    .eq("id", model)
    .single();

  const cost = registry
    ? (usage.prompt_tokens / 1_000_000) * registry.pricing_prompt
      + (usage.completion_tokens / 1_000_000) * registry.pricing_completion
    : 0;

  await client.from("token_usage").insert({
    user_id: userId,
    model_id: model,
    provider_used: "platform_background",
    prompt_tokens: usage.prompt_tokens,
    completion_tokens: usage.completion_tokens,
    total_tokens: usage.prompt_tokens + usage.completion_tokens,
    estimated_cost_usd: cost,
    is_byok: false,
    metadata: { task },
  });
}
```

### Background Cost Limits 

To prevent runaway costs, each background task has a maximum cost per invocation:

| Task | Max Tokens (input \+ output) | Estimated Max Cost |
| :---- | :---- | :---- |
| Memory extraction | 3,000 | $0.014 |
| Auto-rename | 500 | $0.002 |
| Suggested move | 1,000 | $0.005 |
| Instruction detection | 500 | $0.002 |
| Checkpoint summary | 1,500 | $0.007 |

If a background task exceeds its token limit, it is truncated rather than allowed to consume unlimited tokens.

---

## 16.9 OpenRouter Integration 

OpenRouter is the primary provider gateway. It routes model calls to the underlying providers and handles load balancing, failover, and unified billing.

### OpenRouter-Specific Headers 

```ts
const OPENROUTER_HEADERS = {
  "HTTP-Referer": "https://aiconnected.app",
  "X-Title": "aiConnected",
};
```

These headers identify aiConnected to OpenRouter for analytics and priority routing.

### OpenRouter Model ID Mapping 

OpenRouter uses namespaced model IDs (e.g. `anthropic/claude-sonnet-4-5-20250929`). The `model_registry.openrouter_model_id` column stores these mapped IDs.

### OpenRouter Billing 

When using the platform OpenRouter key, model costs are billed to aiConnected's OpenRouter account. The `token_usage` table tracks these costs for internal accounting.

When using a user's BYOK OpenRouter key, model costs are billed directly to the user's OpenRouter account. The `token_usage` table still tracks usage with `is_byok = true` for analytics, but the user pays OpenRouter directly.

### OpenRouter Rate Limits 

OpenRouter imposes its own rate limits per API key. When the platform key is rate-limited, Cipher receives a 429 response and the fallback chain activates. BYOK users hitting their OpenRouter rate limit see the same behavior — Cipher tries the fallback model, then the tier default.

### OpenRouter Model Availability Monitoring 

Cipher periodically checks model availability on OpenRouter:

```ts
// Run every 15 minutes via pg_cron
async function checkOpenRouterModels(client: SupabaseClient) {
  const response = await fetch("https://openrouter.ai/api/v1/models", {
    headers: { Authorization: `Bearer ${Deno.env.get("OPENROUTER_API_KEY")}` },
  });

  if (!response.ok) return;

  const { data: models } = await response.json();
  const availableIds = new Set(models.map((m: any) => m.id));

  // Check each registry entry
  const { data: registry } = await client
    .from("model_registry")
    .select("id, openrouter_model_id, status")
    .eq("status", "active");

  for (const entry of registry ?? []) {
    if (!availableIds.has(entry.openrouter_model_id)) {
      // Model no longer available on OpenRouter
      cipherLog("warn", "model_unavailable_openrouter", {
        model: entry.id,
        openrouter_id: entry.openrouter_model_id,
      });
      // Do NOT auto-disable — just log for manual review
    }
  }
}
```

---

## 16.10 Image Generation 

DALL-E 3 is included in the model registry for the `creative` role. Image generation follows a different call pattern than text generation — it does not stream and returns a URL rather than text content.

### Image Generation Flow 

```ts
async function generateImage(
  route: { apiKey: string; baseURL: string },
  prompt: string,
  options: {
    size: "1024x1024" | "1792x1024" | "1024x1792";
    quality: "standard" | "hd";
    style: "natural" | "vivid";
  }
): Promise<{ url: string; revised_prompt: string }> {
  const response = await fetch(`${route.baseURL}/images/generations`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${route.apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "dall-e-3",
      prompt,
      n: 1,
      size: options.size,
      quality: options.quality,
      style: options.style,
      response_format: "url",
    }),
  });

  if (!response.ok) throw { status: response.status, message: await response.text() };

  const data = await response.json();
  return {
    url: data.data[0].url,
    revised_prompt: data.data[0].revised_prompt,
  };
}
```

When a persona with the `creative` role assignment routes to DALL-E 3, Cipher detects the image generation intent and switches to the image flow. The generated image URL is stored as a content block in the message:

```ts
// Content block for generated images
{
  type: "image",
  url: generatedImageUrl,
  alt: revisedPrompt,
  width: parseInt(size.split("x")[0]),
  height: parseInt(size.split("x")[1]),
}
```

### Image Generation Detection 

```ts
function isImageGenerationRequest(message: string, modelId: string): boolean {
  if (modelId !== "dall-e-3") return false;

  const imageKeywords = [
    "generate", "create", "draw", "make", "design", "paint",
    "illustrate", "render", "sketch", "visualize",
  ];
  const imageNouns = [
    "image", "picture", "photo", "illustration", "artwork",
    "logo", "icon", "banner", "poster", "mockup",
  ];

  const lower = message.toLowerCase();
  const hasVerb = imageKeywords.some(k => lower.includes(k));
  const hasNoun = imageNouns.some(k => lower.includes(k));

  return hasVerb || hasNoun;
}
```

---

## 16.11 Model Health Monitoring 

Model health is tracked to identify degraded models before users notice issues. This data feeds into the Insights Analytics (Part 12\) and informs fallback decisions.

### Metrics per Model 

```sql
CREATE OR REPLACE FUNCTION model_health_stats(
  p_user_id UUID,
  p_hours INTEGER DEFAULT 24
)
RETURNS TABLE (
  model_id TEXT,
  call_count BIGINT,
  error_count BIGINT,
  avg_latency_ms NUMERIC,
  p95_latency_ms NUMERIC,
  success_rate NUMERIC,
  avg_completion_tokens NUMERIC
)
LANGUAGE sql STABLE AS $$
  SELECT
    tu.model_id,
    COUNT(*) AS call_count,
    COUNT(*) FILTER (WHERE tu.latency_ms IS NULL OR tu.total_tokens = 0) AS error_count,
    AVG(tu.latency_ms) FILTER (WHERE tu.latency_ms IS NOT NULL) AS avg_latency_ms,
    PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY tu.latency_ms)
      FILTER (WHERE tu.latency_ms IS NOT NULL) AS p95_latency_ms,
    ROUND(
      COUNT(*) FILTER (WHERE tu.latency_ms IS NOT NULL AND tu.total_tokens > 0)::numeric
      / NULLIF(COUNT(*), 0) * 100, 1
    ) AS success_rate,
    AVG(tu.completion_tokens) FILTER (WHERE tu.completion_tokens > 0) AS avg_completion_tokens
  FROM token_usage tu
  WHERE tu.user_id = p_user_id
    AND tu.created_at > now() - (p_hours || ' hours')::interval
  GROUP BY tu.model_id
  ORDER BY call_count DESC;
$$;
```

### Health Thresholds 

| Metric | Healthy | Degraded | Unhealthy |
| :---- | :---- | :---- | :---- |
| Success rate | \> 95% | 80–95% | \< 80% |
| Avg latency | \< 5s | 5–15s | \> 15s |
| P95 latency | \< 15s | 15–30s | \> 30s |

When a model is detected as `unhealthy` for a user, Cipher automatically prefers the fallback model for that role until the primary recovers. Recovery is detected on the next successful call.

---

## 16.12 Cross-Reference Summary 

| Component | Defined In | Used By |
| :---- | :---- | :---- |
| `model_registry` table | Part 16 (this part) | Parts 13, 14, 16 |
| `model_role_assignments` table | Part 13 | Parts 14, 16 |
| `api_keys` table | Part 2 | Parts 13, 14, 16 |
| `token_usage` table | Part 16 (this part) | Parts 12, 16 |
| `rate_limit_counters` table | Part 16 (this part) | Part 14, 16 |
| `AVAILABLE_MODELS` client registry | Part 13 | Superseded by `model_registry` server query |
| `selectModel()` | Part 14 | Wraps Part 16 priority chain |
| `resolveApiKey()` | Part 14 | Wraps Part 16 provider routing |
| `streamResponse()` | Part 14 | Uses Part 16 streaming protocol |
| `analytics_model_usage` RPC | Part 12 | Reads `token_usage` (Part 16\) and `messages` (Part 5\) |
| `estimateCost()` | Part 12 | Uses `model_registry.pricing_*` (Part 16\) |

---

*End of Part 16\. Proceed to Part 17: Real-Time & Collaboration.*

# PART 17: REAL-TIME & COLLABORATION 

---

Real-time functionality is what makes aiConnected feel alive. Persona responses stream token-by-token, typing indicators pulse while models think, new notifications appear instantly, activity feeds update without refresh, and chat lists reorder as conversations become active. All of this is powered by a combination of Supabase Realtime (for database change events and ephemeral broadcasts) and Server-Sent Events (for streaming model output).

Parts 3, 4, 5, and 14 introduced real-time behaviors in context — the activity feed subscription (Part 4), notification delivery (Part 3), streaming responses (Part 5), and the SSE protocol (Parts 14 and 16). This part unifies every real-time channel, defines the subscription architecture, documents typing indicators, specifies toast notifications, details optimistic update patterns, and outlines the future multi-user presence system.

---

## 17.1 Architecture Overview 

aiConnected uses two real-time transport mechanisms:

**Supabase Realtime** — For database-driven events (new messages, updated records, notifications) and ephemeral broadcasts (typing indicators, status signals). Realtime operates over WebSocket connections and requires no polling.

**Server-Sent Events (SSE)** — For streaming model output from Edge Functions. SSE is a one-directional HTTP-based protocol ideal for progressive token delivery. It is used exclusively for the `cipher-route` response stream (Part 14).

```
┌──────────────────────────────────────────────────────────┐
│                     Client Application                    │
│                                                           │
│  ┌─────────────────────┐   ┌───────────────────────────┐ │
│  │  Supabase Realtime   │   │  SSE (fetch ReadableStream)│ │
│  │  (WebSocket)         │   │  (HTTP streaming)          │ │
│  │                      │   │                            │ │
│  │  • postgres_changes  │   │  • Token streaming         │ │
│  │  • broadcast         │   │  • Status events           │ │
│  │  • presence          │   │  • Error events            │ │
│  └──────────┬───────────┘   └──────────┬─────────────────┘ │
│             │                          │                    │
└─────────────┼──────────────────────────┼────────────────────┘
              │                          │
              ▼                          ▼
┌─────────────────────────┐  ┌────────────────────────────┐
│   Supabase Platform     │  │  cipher-route Edge Function │
│                         │  │                             │
│  • Realtime server      │  │  • Model call               │
│  • Postgres WAL         │  │  • Token-by-token output    │
│  • Broadcast channels   │  │  • Content-Type:            │
│  • Presence tracking    │  │    text/event-stream        │
└─────────────────────────┘  └─────────────────────────────┘
```

### Why Two Transports 

SSE is used for streaming because it delivers tokens with minimal latency (no WebSocket framing overhead) and integrates naturally with the HTTP request/response cycle of Edge Functions. Supabase Realtime is used for everything else because it provides built-in support for database change tracking, broadcast channels, and presence — all managed by Supabase infrastructure without custom server code.

---

## 17.2 Channel Architecture 

Every real-time subscription in aiConnected is organized into named channels. Each channel has a specific scope and set of events.

### Channel Registry 

| Channel Name | Scope | Transport | Events | Subscribers |
| :---- | :---- | :---- | :---- | :---- |
| `chat:{chat_id}` | Single conversation | Realtime (postgres\_changes \+ broadcast) | New messages, message updates, typing indicators, participant changes, auto-rename, suggested move | Chat screen |
| `instance:{instance_id}` | Single Instance | Realtime (postgres\_changes) | New chats, task updates, file uploads, setting changes | Instance detail, sidebar |
| `user:{user_id}` | User-global | Realtime (postgres\_changes \+ broadcast) | Notifications, persona status changes, system alerts, instruction\_memory changes, chat list updates | App shell (always active) |
| `browser:{session_id}` | Browser session | Realtime (broadcast) | Tab changes, navigation events, extraction completion | Browser screen |
| `stream:{chat_id}:{message_id}` | Single response | SSE | Token chunks, completion, errors | Chat screen (active stream) |

### Subscription Lifecycle 

Channels are subscribed when the user enters a screen and unsubscribed when they leave. The `user:{user_id}` channel is the exception — it is subscribed on app boot and persists for the entire session.

```ts
// src/hooks/useRealtimeChannel.ts

function useRealtimeChannel(
  channelName: string,
  handlers: ChannelHandler[],
  enabled: boolean = true
) {
  const supabase = useSupabaseClient();

  useEffect(() => {
    if (!enabled) return;

    let channel = supabase.channel(channelName);

    for (const handler of handlers) {
      switch (handler.type) {
        case "postgres_changes":
          channel = channel.on(
            "postgres_changes",
            {
              event: handler.event,
              schema: "public",
              table: handler.table,
              filter: handler.filter,
            },
            handler.callback
          );
          break;

        case "broadcast":
          channel = channel.on(
            "broadcast",
            { event: handler.event },
            handler.callback
          );
          break;

        case "presence":
          channel = channel.on("presence", { event: handler.event }, handler.callback);
          break;
      }
    }

    channel.subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [channelName, enabled]);
}

interface PostgresChangeHandler {
  type: "postgres_changes";
  event: "INSERT" | "UPDATE" | "DELETE" | "*";
  table: string;
  filter?: string;
  callback: (payload: any) => void;
}

interface BroadcastHandler {
  type: "broadcast";
  event: string;
  callback: (payload: any) => void;
}

interface PresenceHandler {
  type: "presence";
  event: "sync" | "join" | "leave";
  callback: (payload: any) => void;
}

type ChannelHandler = PostgresChangeHandler | BroadcastHandler | PresenceHandler;
```

---

## 17.3 Chat Channel (`chat:{chat_id}`) 

The chat channel is the most event-rich channel in the system. It handles everything that happens within a conversation.

### Subscription Setup 

```ts
// src/hooks/useChatRealtime.ts

function useChatRealtime(chatId: string) {
  const queryClient = useQueryClient();
  const supabase = useSupabaseClient();

  useRealtimeChannel(
    `chat:${chatId}`,
    [
      // 1. New messages (from personas or system)
      {
        type: "postgres_changes",
        event: "INSERT",
        table: "messages",
        filter: `chat_id=eq.${chatId}`,
        callback: (payload) => {
          handleNewMessage(queryClient, chatId, payload.new);
        },
      },

      // 2. Message updates (pin, edit metadata)
      {
        type: "postgres_changes",
        event: "UPDATE",
        table: "messages",
        filter: `chat_id=eq.${chatId}`,
        callback: (payload) => {
          handleMessageUpdate(queryClient, chatId, payload.new);
        },
      },

      // 3. Chat metadata updates (auto_title, suggested_instance, etc.)
      {
        type: "postgres_changes",
        event: "UPDATE",
        table: "chats",
        filter: `id=eq.${chatId}`,
        callback: (payload) => {
          handleChatUpdate(queryClient, chatId, payload.new);
        },
      },

      // 4. Participant changes
      {
        type: "postgres_changes",
        event: "*",
        table: "chat_participants",
        filter: `chat_id=eq.${chatId}`,
        callback: (payload) => {
          queryClient.invalidateQueries({ queryKey: ["chat-participants", chatId] });
        },
      },

      // 5. Typing indicators (ephemeral broadcast)
      {
        type: "broadcast",
        event: "typing",
        callback: (payload) => {
          handleTypingIndicator(payload.payload);
        },
      },

      // 6. Stream status (routing, generating, saving)
      {
        type: "broadcast",
        event: "stream_status",
        callback: (payload) => {
          handleStreamStatus(payload.payload);
        },
      },
    ],
    !!chatId
  );
}
```

### Event Handlers 

#### New Message 

When a new message is inserted (by the Edge Function after a persona response, or by another future client in multi-user scenarios), the handler prepends it to the cached message list:

```ts
function handleNewMessage(
  queryClient: QueryClient,
  chatId: string,
  message: Message
) {
  queryClient.setQueryData<Message[]>(
    ["messages", chatId],
    (old) => {
      if (!old) return [message];

      // Deduplicate — the message may already exist from optimistic insert
      const exists = old.some(m => m.id === message.id);
      if (exists) {
        // Replace the optimistic version with the server version
        return old.map(m => m.id === message.id ? message : m);
      }

      return [...old, message];
    }
  );

  // Also update the chat list's last_message preview
  queryClient.setQueryData<Chat[]>(
    ["chat-list"],
    (old) => {
      if (!old) return old;
      return old.map(chat => {
        if (chat.id !== chatId) return chat;
        return {
          ...chat,
          updated_at: message.created_at,
          last_message_preview: message.content.slice(0, 120),
          last_message_sender: message.sender_name,
        };
      });
    }
  );
}
```

#### Message Update 

Handles pin toggles, metadata updates (memories\_saved count), and content edits:

```ts
function handleMessageUpdate(
  queryClient: QueryClient,
  chatId: string,
  updatedMessage: Message
) {
  queryClient.setQueryData<Message[]>(
    ["messages", chatId],
    (old) => {
      if (!old) return old;
      return old.map(m => m.id === updatedMessage.id ? updatedMessage : m);
    }
  );
}
```

#### Chat Update 

Handles auto-rename banners, suggested move banners, and metadata changes:

```ts
function handleChatUpdate(
  queryClient: QueryClient,
  chatId: string,
  updatedChat: Chat
) {
  // Update the active chat data
  queryClient.setQueryData<Chat>(
    ["chat", chatId],
    (old) => {
      if (!old) return updatedChat;
      return { ...old, ...updatedChat };
    }
  );

  // Update the chat list entry
  queryClient.setQueryData<Chat[]>(
    ["chat-list"],
    (old) => {
      if (!old) return old;
      return old.map(c => c.id === chatId ? { ...c, ...updatedChat } : c);
    }
  );
}
```

### Auto-Rename Banner 

When `cipher-memory` updates `chats.auto_title` (Part 14), the chat channel's `UPDATE` handler detects the change and shows a rename banner:

```ts
// Detected in handleChatUpdate when auto_title transitions from null to a value
const showRenameBanner = updatedChat.auto_title && !old?.auto_title;
```

**Banner UI** (from Part 5, fully specified here):

```ts
// Rename suggestion banner — appears between top bar and message list
{
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  padding: "8px 14px",
  background: theme.surfaceAlt,
  borderBottom: `1px solid ${theme.borderSubtle}`,
  gap: 10,
}
```

**Left side:**

- Edit3 icon (12px, `theme.accent`)  
- "Rename to" (11px, weight 350, `textMuted`)  
- Suggested title in quotes (11px, weight 450, `text`)

**Right side:**

- "Accept" button (10px, weight 450, accent bg, accentText color, 4px 10px padding, borderRadius 6\)  
- "Dismiss" button (10px, weight 350, textMuted, no background, border: none)

**Accept:** Updates `chats.title` to the `auto_title` value. Clears the banner.

```ts
async function acceptAutoRename(chatId: string, autoTitle: string) {
  const supabase = createClient();
  await supabase
    .from("chats")
    .update({ title: autoTitle })
    .eq("id", chatId);
}
```

**Dismiss:** Clears the banner without renaming. Sets `metadata.auto_rename_dismissed = true` so the banner does not reappear.

```ts
async function dismissAutoRename(chatId: string) {
  const supabase = createClient();
  const { data } = await supabase
    .from("chats")
    .select("metadata")
    .eq("id", chatId)
    .single();

  await supabase
    .from("chats")
    .update({
      metadata: { ...(data?.metadata ?? {}), auto_rename_dismissed: true },
    })
    .eq("id", chatId);
}
```

### Suggested Move Banner 

When `cipher-memory` sets `chats.metadata.suggested_instance_id` (Part 14), a similar banner appears:

```ts
// Same container style as rename banner
```

**Left side:**

- FolderInput icon (12px, `theme.accent`)  
- "Move to" (11px, weight 350, `textMuted`)  
- Instance name (11px, weight 450, `text`)

**Right side:**

- "Move" button (same style as "Accept" above)  
- "Dismiss" button

**Move:** Updates `chats.instance_id` to the suggested instance. Removes the suggestion from metadata.

```ts
async function acceptSuggestedMove(chatId: string, instanceId: string) {
  const supabase = createClient();
  await supabase
    .from("chats")
    .update({
      instance_id: instanceId,
      metadata: {},
    })
    .eq("id", chatId);
}
```

**Dismiss:** Sets `metadata.suggested_move_dismissed = true`.

---

## 17.4 Typing Indicators 

Typing indicators show when a persona is "thinking" — the gap between when the user sends a message and when the response starts streaming. This makes the interface feel responsive even during the routing and model invocation phase.

### Indicator Lifecycle 

```
User sends message
        │
        ▼
┌─────────────────────┐
│  Broadcast "typing"  │  ◀── cipher-route sends this before model call
│  persona_id, started │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  Client shows        │  Persona avatar + animated dots
│  typing indicator    │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  First SSE token     │  ◀── Model starts streaming
│  arrives             │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  Broadcast "stopped" │  ◀── cipher-route sends this when streaming starts
│  persona_id          │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  Client hides        │  Indicator replaced by streaming content
│  typing indicator    │
└──────────────────────┘
```

### Server-Side Broadcast 

The `cipher-route` Edge Function broadcasts typing events on the chat channel before and after the model call:

```ts
// In cipher-route, after routing and before calling the model

// Signal typing started
await supabase.channel(`chat:${chatId}`).send({
  type: "broadcast",
  event: "typing",
  payload: {
    persona_id: selectedPersonaId,
    persona_name: selectedPersona.name,
    status: "started",
    timestamp: new Date().toISOString(),
  },
});

// ... model call begins, first token arrives ...

// Signal typing stopped (transition to streaming)
await supabase.channel(`chat:${chatId}`).send({
  type: "broadcast",
  event: "typing",
  payload: {
    persona_id: selectedPersonaId,
    persona_name: selectedPersona.name,
    status: "stopped",
    timestamp: new Date().toISOString(),
  },
});
```

### Client-Side Typing State 

```ts
// src/hooks/useTypingIndicator.ts

interface TypingState {
  personaId: string;
  personaName: string;
  startedAt: number;
}

function useTypingIndicator(chatId: string) {
  const [typing, setTyping] = useState<TypingState | null>(null);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  const handleTypingIndicator = useCallback((payload: any) => {
    if (payload.status === "started") {
      setTyping({
        personaId: payload.persona_id,
        personaName: payload.persona_name,
        startedAt: Date.now(),
      });

      // Safety timeout: auto-clear after 30 seconds in case "stopped" is missed
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
      timeoutRef.current = setTimeout(() => {
        setTyping(null);
      }, 30_000);
    } else if (payload.status === "stopped") {
      setTyping(null);
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    }
  }, []);

  useEffect(() => {
    return () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, []);

  return { typing, handleTypingIndicator };
}
```

### Typing Indicator UI 

The typing indicator renders below the last message in the message list, matching the message layout:

```ts
// Typing indicator component
function TypingIndicator({ typing }: { typing: TypingState }) {
  return (
    <div style={{
      display: "flex",
      alignItems: "center",
      gap: 8,
      padding: "10px 14px 10px 20px",
    }}>
      {/* Persona avatar */}
      <PersonaAvatar
        name={typing.personaName}
        size={22}
      />

      {/* Text + dots */}
      <div style={{
        display: "flex",
        alignItems: "center",
        gap: 4,
      }}>
        <span style={{
          fontSize: 11,
          color: theme.textMuted,
          fontWeight: 350,
        }}>
          {typing.personaName} is thinking
        </span>
        <TypingDots />
      </div>
    </div>
  );
}
```

### Animated Dots 

```ts
// CSS-in-JS keyframes for typing dots
const typingDotsStyle = `
  @keyframes typingBounce {
    0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
    30% { transform: translateY(-3px); opacity: 1; }
  }
`;

function TypingDots() {
  return (
    <span style={{ display: "inline-flex", gap: 2, marginLeft: 2 }}>
      {[0, 1, 2].map(i => (
        <span
          key={i}
          style={{
            width: 4,
            height: 4,
            borderRadius: "50%",
            background: theme.textMuted,
            animation: `typingBounce 1.2s ease-in-out infinite`,
            animationDelay: `${i * 0.15}s`,
          }}
        />
      ))}
    </span>
  );
}
```

### Multi-Persona Typing 

In multi-persona conversations (team chats), multiple personas might be "thinking" simultaneously during team execution (Part 10). The typing indicator supports stacking:

```ts
// Extended typing state for multi-persona
const [typingPersonas, setTypingPersonas] = useState<Map<string, TypingState>>(new Map());

// Handler updates the map
function handleMultiTyping(payload: any) {
  setTypingPersonas(prev => {
    const next = new Map(prev);
    if (payload.status === "started") {
      next.set(payload.persona_id, {
        personaId: payload.persona_id,
        personaName: payload.persona_name,
        startedAt: Date.now(),
      });
    } else {
      next.delete(payload.persona_id);
    }
    return next;
  });
}

// Render: show up to 3 personas
function MultiTypingIndicator({ typingPersonas }: { typingPersonas: Map<string, TypingState> }) {
  const personas = Array.from(typingPersonas.values());
  if (personas.length === 0) return null;

  const names = personas.map(p => p.personaName);
  const displayText = names.length === 1
    ? `${names[0]} is thinking`
    : names.length === 2
    ? `${names[0]} and ${names[1]} are thinking`
    : `${names[0]} and ${names.length - 1} others are thinking`;

  return (
    <div style={{
      display: "flex",
      alignItems: "center",
      gap: 8,
      padding: "10px 14px 10px 20px",
    }}>
      {/* Stacked avatars */}
      <div style={{ display: "flex", marginLeft: 0 }}>
        {personas.slice(0, 3).map((p, i) => (
          <PersonaAvatar
            key={p.personaId}
            name={p.personaName}
            size={20}
            style={{ marginLeft: i > 0 ? -6 : 0, zIndex: 3 - i }}
          />
        ))}
      </div>

      <span style={{ fontSize: 11, color: theme.textMuted, fontWeight: 350 }}>
        {displayText}
      </span>
      <TypingDots />
    </div>
  );
}
```

---

## 17.5 Stream Status Events 

Beyond typing indicators, Cipher broadcasts stream status events to give the client fine-grained progress information during the response pipeline:

### Status Events 

| Status | Emitted When | Client Display |
| :---- | :---- | :---- |
| `routing` | After receiving message, before model selection | Typing indicator text: "{name} is routing..." |
| `generating` | First token received from model | Typing indicator replaced by streaming content |
| `saving` | Stream complete, saving to database | None (transparent to user) |
| `extracting` | Post-response memory extraction begins | Subtle indicator (optional): "Learning..." |
| `error` | Pipeline error occurred | Error message in chat |

### Server-Side Status Emission 

```ts
// In cipher-route pipeline

// After auth + rate limit + data loading
await broadcastStatus(supabase, chatId, personaId, personaName, "routing");

// After model selection, before streaming
await broadcastTyping(supabase, chatId, personaId, personaName, "started");

// On first token
await broadcastTyping(supabase, chatId, personaId, personaName, "stopped");
await broadcastStatus(supabase, chatId, personaId, personaName, "generating");

// After stream complete
await broadcastStatus(supabase, chatId, personaId, personaName, "saving");

// Helper
async function broadcastStatus(
  supabase: SupabaseClient,
  chatId: string,
  personaId: string,
  personaName: string,
  status: string
) {
  await supabase.channel(`chat:${chatId}`).send({
    type: "broadcast",
    event: "stream_status",
    payload: { persona_id: personaId, persona_name: personaName, status },
  });
}

async function broadcastTyping(
  supabase: SupabaseClient,
  chatId: string,
  personaId: string,
  personaName: string,
  typingStatus: "started" | "stopped"
) {
  await supabase.channel(`chat:${chatId}`).send({
    type: "broadcast",
    event: "typing",
    payload: {
      persona_id: personaId,
      persona_name: personaName,
      status: typingStatus,
      timestamp: new Date().toISOString(),
    },
  });
}
```

---

## 17.6 User Channel (`user:{user_id}`) 

The user channel is the global event bus. It is subscribed on app boot and remains active for the entire session. It delivers notifications, persona status changes, and system-wide updates.

### Subscription Setup 

```ts
// src/providers/RealtimeProvider.tsx

function RealtimeProvider({ children }: { children: React.ReactNode }) {
  const { session } = useAuth();
  const queryClient = useQueryClient();
  const supabase = useSupabaseClient();

  useEffect(() => {
    if (!session?.user?.id) return;

    const userId = session.user.id;

    const channel = supabase
      .channel(`user:${userId}`)

      // Notifications
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "notifications",
          filter: `user_id=eq.${userId}`,
        },
        (payload) => {
          handleNewNotification(queryClient, payload.new);
        }
      )

      // Persona status changes (mood, drift_level)
      .on(
        "postgres_changes",
        {
          event: "UPDATE",
          schema: "public",
          table: "personas",
          filter: `user_id=eq.${userId}`,
        },
        (payload) => {
          handlePersonaUpdate(queryClient, payload.new);
        }
      )

      // Instruction memory changes (new rules detected)
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "instruction_memory",
          filter: `user_id=eq.${userId}`,
        },
        (payload) => {
          handleNewInstruction(queryClient, payload.new);
        }
      )

      // Chat list updates (new chats, title changes)
      .on(
        "postgres_changes",
        {
          event: "*",
          schema: "public",
          table: "chats",
          filter: `user_id=eq.${userId}`,
        },
        (payload) => {
          handleChatListUpdate(queryClient, payload.eventType, payload.new, payload.old);
        }
      )

      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [session?.user?.id]);

  return <>{children}</>;
}
```

### Notification Handler 

When a new notification is inserted, the handler updates the notification cache and triggers UI changes:

```ts
function handleNewNotification(queryClient: QueryClient, notification: Notification) {
  // Update notifications cache
  queryClient.setQueryData<Notification[]>(
    ["notifications"],
    (old) => {
      if (!old) return [notification];
      return [notification, ...old].slice(0, 50);
    }
  );

  // Update unread count
  queryClient.setQueryData<number>(
    ["unread-notification-count"],
    (old) => (old ?? 0) + 1
  );

  // Show toast
  showToast({
    type: notificationTypeToToastType(notification.type),
    title: notification.text,
    duration: 4000,
  });
}

function notificationTypeToToastType(type: string): ToastType {
  switch (type) {
    case "persona": return "info";
    case "system": return "neutral";
    case "chat": return "info";
    default: return "neutral";
  }
}
```

### Instruction Memory Handler 

When Cipher detects a new instruction rule (Part 14), the handler shows an inline toast with an undo action:

```ts
function handleNewInstruction(queryClient: QueryClient, instruction: InstructionMemoryRow) {
  queryClient.invalidateQueries({ queryKey: ["instruction-memory"] });

  showToast({
    type: "learned",
    title: `Learned: ${instruction.rule.slice(0, 60)}${instruction.rule.length > 60 ? "..." : ""}`,
    duration: 6000,
    action: {
      label: "Undo",
      onClick: async () => {
        const supabase = createClient();
        await supabase
          .from("instruction_memory")
          .update({ active: false })
          .eq("id", instruction.id);
        queryClient.invalidateQueries({ queryKey: ["instruction-memory"] });
      },
    },
  });
}
```

### Chat List Handler 

Keeps the sidebar chat list in sync without polling:

```ts
function handleChatListUpdate(
  queryClient: QueryClient,
  eventType: string,
  newRecord: Chat | null,
  oldRecord: Chat | null
) {
  switch (eventType) {
    case "INSERT":
      if (newRecord) {
        queryClient.setQueryData<Chat[]>(
          ["chat-list"],
          (old) => {
            if (!old) return [newRecord];
            return [newRecord, ...old];
          }
        );
      }
      break;

    case "UPDATE":
      if (newRecord) {
        queryClient.setQueryData<Chat[]>(
          ["chat-list"],
          (old) => {
            if (!old) return old;
            return old
              .map(c => c.id === newRecord.id ? { ...c, ...newRecord } : c)
              .sort((a, b) =>
                new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
              );
          }
        );
      }
      break;

    case "DELETE":
      if (oldRecord) {
        queryClient.setQueryData<Chat[]>(
          ["chat-list"],
          (old) => {
            if (!old) return old;
            return old.filter(c => c.id !== oldRecord.id);
          }
        );
      }
      break;
  }
}
```

### Persona Update Handler 

When persona mood, status, or drift\_level changes (from `cipher-health`, Part 14), the handler updates the cached persona data:

```ts
function handlePersonaUpdate(queryClient: QueryClient, updatedPersona: Persona) {
  // Update personas list cache
  queryClient.setQueryData<Persona[]>(
    ["personas"],
    (old) => {
      if (!old) return old;
      return old.map(p => p.id === updatedPersona.id ? { ...p, ...updatedPersona } : p);
    }
  );

  // Update individual persona cache
  queryClient.setQueryData<Persona>(
    ["persona", updatedPersona.id],
    (old) => old ? { ...old, ...updatedPersona } : updatedPersona
  );
}
```

---

## 17.7 Instance Channel (`instance:{instance_id}`) 

The instance channel broadcasts changes within a workspace context. It is subscribed when the user is viewing an instance detail or a chat within that instance.

### Subscription Setup 

```ts
function useInstanceRealtime(instanceId: string | null) {
  const queryClient = useQueryClient();

  useRealtimeChannel(
    `instance:${instanceId}`,
    [
      // New chats created in this instance
      {
        type: "postgres_changes",
        event: "INSERT",
        table: "chats",
        filter: `instance_id=eq.${instanceId}`,
        callback: (payload) => {
          queryClient.invalidateQueries({ queryKey: ["instance-chats", instanceId] });
        },
      },

      // File uploads to this instance
      {
        type: "postgres_changes",
        event: "INSERT",
        table: "files",
        filter: `instance_id=eq.${instanceId}`,
        callback: (payload) => {
          queryClient.invalidateQueries({ queryKey: ["instance-files", instanceId] });
        },
      },

      // Instance settings changes
      {
        type: "postgres_changes",
        event: "UPDATE",
        table: "instances",
        filter: `id=eq.${instanceId}`,
        callback: (payload) => {
          queryClient.setQueryData<Instance>(
            ["instance", instanceId],
            (old) => old ? { ...old, ...payload.new } : payload.new
          );
        },
      },

      // Persona assignments change
      {
        type: "postgres_changes",
        event: "*",
        table: "instance_personas",
        filter: `instance_id=eq.${instanceId}`,
        callback: () => {
          queryClient.invalidateQueries({ queryKey: ["instance-personas", instanceId] });
        },
      },
    ],
    !!instanceId
  );
}
```

---

## 17.8 Browser Channel (`browser:{session_id}`) 

The browser channel handles ephemeral events during co-browsing sessions (Part 11). These events are broadcast-only — they do not correspond to database changes.

### Events 

| Event | Payload | Purpose |
| :---- | :---- | :---- |
| `navigation` | `{ tab_id, url, title }` | Tab navigated to a new URL |
| `tab_switch` | `{ tab_id }` | Active tab changed |
| `extraction_complete` | `{ tab_id, extract_id, preview }` | Page content extraction finished |
| `highlight_added` | `{ tab_id, highlight_id, text }` | User highlighted text on page |

### Subscription 

```ts
function useBrowserRealtime(sessionId: string | null) {
  const queryClient = useQueryClient();

  useRealtimeChannel(
    `browser:${sessionId}`,
    [
      {
        type: "broadcast",
        event: "navigation",
        callback: (payload) => {
          queryClient.invalidateQueries({ queryKey: ["browser-tabs", sessionId] });
        },
      },
      {
        type: "broadcast",
        event: "tab_switch",
        callback: (payload) => {
          queryClient.setQueryData(
            ["browser-active-tab", sessionId],
            payload.payload.tab_id
          );
        },
      },
      {
        type: "broadcast",
        event: "extraction_complete",
        callback: (payload) => {
          showToast({
            type: "info",
            title: "Page content extracted",
            duration: 3000,
          });
          queryClient.invalidateQueries({ queryKey: ["browser-extracts", sessionId] });
        },
      },
    ],
    !!sessionId
  );
}
```

---

## 17.9 Toast Notification System 

Toasts are ephemeral, non-blocking notifications that appear at the bottom of the screen. They communicate transient events: learned instructions, auto-rename suggestions, errors, confirmations, and real-time status updates.

### Toast Types 

| Type | Color | Icon | Duration | Example |
| :---- | :---- | :---- | :---- | :---- |
| `info` | `theme.accent` | Info (12px) | 4s | "New message from Sally" |
| `success` | `#22c55e` | Check (12px) | 3s | "Chat renamed successfully" |
| `error` | `#ef4444` | AlertCircle (12px) | 6s | "Failed to send message" |
| `learned` | `#818cf8` | Sparkles (12px) | 6s | "Learned: Always use TypeScript strict mode" |
| `neutral` | `theme.textMuted` | Bell (12px) | 4s | "Weekly summary available" |

### Toast Container 

Toasts stack vertically from the bottom-left of the viewport:

```ts
// src/components/ToastContainer.tsx

{
  position: "fixed",
  bottom: 20,
  left: 20,
  zIndex: 100,
  display: "flex",
  flexDirection: "column-reverse",
  gap: 8,
  maxWidth: 380,
  pointerEvents: "none", // Allow clicks through empty space
}
```

### Individual Toast 

```ts
// Each toast item
{
  display: "flex",
  alignItems: "flex-start",
  gap: 10,
  padding: "10px 14px",
  background: theme.surface,
  border: `1px solid ${theme.border}`,
  borderRadius: 10,
  boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
  pointerEvents: "auto",
  animation: "slideInFromLeft 0.2s ease-out",
  maxWidth: 380,
}
```

**Content area** (flex: 1):

- Icon (12px, type-color) \+ Title text (12px, weight 400, `text`, line-height 1.4)  
- Optional action button below title: (10px, weight 450, type-color, cursor pointer, no background, no border, textDecoration underline on hover)

**Close button** (right side):

- X icon (10px, `textFaint`), cursor pointer, opacity 0.6 → 1 on hover

### Toast State Management 

```ts
// src/stores/toastStore.ts

interface Toast {
  id: string;
  type: "info" | "success" | "error" | "learned" | "neutral";
  title: string;
  duration: number;
  action?: {
    label: string;
    onClick: () => void;
  };
}

// Using Zustand or a simple React context
interface ToastStore {
  toasts: Toast[];
  addToast: (toast: Omit<Toast, "id">) => void;
  removeToast: (id: string) => void;
}

const useToastStore = create<ToastStore>((set) => ({
  toasts: [],
  addToast: (toast) => {
    const id = crypto.randomUUID();
    set(state => ({
      toasts: [...state.toasts, { ...toast, id }].slice(-5), // Max 5 visible
    }));

    // Auto-remove after duration
    setTimeout(() => {
      set(state => ({
        toasts: state.toasts.filter(t => t.id !== id),
      }));
    }, toast.duration);
  },
  removeToast: (id) => {
    set(state => ({
      toasts: state.toasts.filter(t => t.id !== id),
    }));
  },
}));

// Convenience function used across the app
function showToast(options: Omit<Toast, "id">) {
  useToastStore.getState().addToast(options);
}
```

### Toast Entry Animation 

```css
@keyframes slideInFromLeft {
  0% {
    transform: translateX(-20px);
    opacity: 0;
  }
  100% {
    transform: translateX(0);
    opacity: 1;
  }
}

@keyframes slideOutToLeft {
  0% {
    transform: translateX(0);
    opacity: 1;
  }
  100% {
    transform: translateX(-20px);
    opacity: 0;
  }
}
```

---

## 17.10 Optimistic Updates 

Optimistic updates make the UI feel instant by applying changes locally before server confirmation. If the server rejects the change, the optimistic update is rolled back.

### Pattern: Send Message 

The most critical optimistic update — the user's message appears immediately in the chat without waiting for the server round-trip:

```ts
async function sendMessage(chatId: string, content: string, senderName: string) {
  const optimisticId = crypto.randomUUID();
  const optimisticMessage: Message = {
    id: optimisticId,
    chat_id: chatId,
    sender_type: "user",
    sender_id: null,
    sender_name: senderName,
    content,
    content_blocks: [],
    model_used: null,
    routing_reason: null,
    token_count: null,
    latency_ms: null,
    pinned: false,
    has_link: containsUrl(content),
    has_media: false,
    metadata: { optimistic: true },
    created_at: new Date().toISOString(),
  };

  // Step 1: Optimistically add to message list
  queryClient.setQueryData<Message[]>(
    ["messages", chatId],
    (old) => [...(old ?? []), optimisticMessage]
  );

  // Step 2: Scroll to bottom
  scrollToBottom();

  // Step 3: Send to server (cipher-route)
  try {
    const response = await fetch(
      `${SUPABASE_URL}/functions/v1/cipher-route`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${session.access_token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          chat_id: chatId,
          content,
          directed_persona_id: directedPersonaId ?? null,
          model_override: modelOverride ?? null,
        }),
      }
    );

    // Streaming response — handled by useStreamingResponse hook
    // The Realtime INSERT event will replace the optimistic message with the real one

  } catch (error) {
    // Step 4: Rollback on failure
    queryClient.setQueryData<Message[]>(
      ["messages", chatId],
      (old) => (old ?? []).filter(m => m.id !== optimisticId)
    );

    showToast({
      type: "error",
      title: "Failed to send message. Please try again.",
      duration: 5000,
    });
  }
}
```

### Pattern: Pin Message 

```ts
async function togglePin(chatId: string, messageId: string, currentlyPinned: boolean) {
  // Optimistic update
  queryClient.setQueryData<Message[]>(
    ["messages", chatId],
    (old) => (old ?? []).map(m =>
      m.id === messageId ? { ...m, pinned: !currentlyPinned } : m
    )
  );

  // Server update
  const supabase = createClient();
  const { error } = await supabase
    .from("messages")
    .update({ pinned: !currentlyPinned })
    .eq("id", messageId);

  if (error) {
    // Rollback
    queryClient.setQueryData<Message[]>(
      ["messages", chatId],
      (old) => (old ?? []).map(m =>
        m.id === messageId ? { ...m, pinned: currentlyPinned } : m
      )
    );
    showToast({ type: "error", title: "Failed to update pin", duration: 3000 });
  }
}
```

### Pattern: Delete Chat 

```ts
async function deleteChat(chatId: string) {
  // Optimistic: remove from list
  const previousChats = queryClient.getQueryData<Chat[]>(["chat-list"]);
  queryClient.setQueryData<Chat[]>(
    ["chat-list"],
    (old) => (old ?? []).filter(c => c.id !== chatId)
  );

  // Server: soft-delete
  const supabase = createClient();
  const { error } = await supabase
    .from("chats")
    .update({ deleted_at: new Date().toISOString() })
    .eq("id", chatId);

  if (error) {
    // Rollback
    queryClient.setQueryData<Chat[]>(["chat-list"], previousChats);
    showToast({ type: "error", title: "Failed to delete chat", duration: 3000 });
  } else {
    showToast({
      type: "success",
      title: "Chat deleted",
      duration: 4000,
      action: {
        label: "Undo",
        onClick: async () => {
          await supabase
            .from("chats")
            .update({ deleted_at: null })
            .eq("id", chatId);
          queryClient.invalidateQueries({ queryKey: ["chat-list"] });
        },
      },
    });
  }
}
```

### Pattern: Update Memory 

```ts
async function updateMemoryContent(
  personaId: string,
  memoryId: string,
  newContent: string,
  oldContent: string
) {
  // Optimistic
  queryClient.setQueryData<PersonaMemory[]>(
    ["persona-memories", personaId],
    (old) => (old ?? []).map(m =>
      m.id === memoryId ? { ...m, content: newContent } : m
    )
  );

  const supabase = createClient();
  const { error } = await supabase
    .from("persona_memories")
    .update({ content: newContent, updated_at: new Date().toISOString() })
    .eq("id", memoryId);

  if (error) {
    queryClient.setQueryData<PersonaMemory[]>(
      ["persona-memories", personaId],
      (old) => (old ?? []).map(m =>
        m.id === memoryId ? { ...m, content: oldContent } : m
      )
    );
    showToast({ type: "error", title: "Failed to update memory", duration: 3000 });
  }
}
```

### Optimistic Update Rules 

| Action | Optimistic? | Rollback Strategy |
| :---- | :---- | :---- |
| Send message | Yes | Remove optimistic message from cache |
| Pin/unpin message | Yes | Revert pin state |
| Delete chat | Yes | Restore chat to list \+ undo toast |
| Delete memory | Yes | Restore memory to list |
| Update memory | Yes | Revert content |
| Create Instance | No | Server creates, then Realtime pushes |
| Create Persona | No | Server creates, triggers redirect |
| Update settings | Yes | Revert to previous value |
| Accept rename | Yes | Revert title |
| Accept move | Yes | Revert instance\_id |

---

## 17.11 Connection State Management 

Network interruptions are handled gracefully. The app tracks WebSocket connection state and shows a reconnection indicator when the Realtime connection drops.

### Connection State 

```ts
// src/hooks/useConnectionState.ts

type ConnectionState = "connected" | "connecting" | "disconnected";

function useConnectionState() {
  const [state, setState] = useState<ConnectionState>("connected");
  const supabase = useSupabaseClient();

  useEffect(() => {
    // Monitor the Realtime connection status
    const channel = supabase.channel("connection-monitor");

    channel
      .on("system", { event: "*" }, (payload) => {
        if (payload.extension === "realtime" && payload.status === "ok") {
          setState("connected");
        }
      })
      .subscribe((status) => {
        switch (status) {
          case "SUBSCRIBED":
            setState("connected");
            break;
          case "CHANNEL_ERROR":
          case "TIMED_OUT":
            setState("disconnected");
            break;
          case "CLOSED":
            setState("disconnected");
            break;
        }
      });

    return () => {
      supabase.removeChannel(channel);
    };
  }, []);

  return state;
}
```

### Reconnection Banner 

When the connection drops, a subtle banner appears at the top of the screen:

```ts
// Connection status banner
function ConnectionBanner() {
  const state = useConnectionState();

  if (state === "connected") return null;

  return (
    <div style={{
      position: "fixed",
      top: 0,
      left: 0,
      right: 0,
      zIndex: 200,
      padding: "6px 14px",
      background: state === "connecting" ? "#f59e0b" : "#ef4444",
      color: "#fff",
      fontSize: 11,
      fontWeight: 450,
      textAlign: "center",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      gap: 6,
    }}>
      {state === "connecting" ? (
        <>
          <Loader2 size={12} className="animate-spin" />
          Reconnecting...
        </>
      ) : (
        <>
          <WifiOff size={12} />
          Connection lost. Changes may not be saved.
          <button
            onClick={() => window.location.reload()}
            style={{
              marginLeft: 8,
              padding: "2px 8px",
              borderRadius: 4,
              background: "rgba(255,255,255,0.2)",
              border: "none",
              color: "#fff",
              fontSize: 10,
              cursor: "pointer",
            }}
          >
            Reload
          </button>
        </>
      )}
    </div>
  );
}
```

### Reconnection Behavior 

Supabase Realtime automatically reconnects with exponential backoff. When the connection is restored:

1. All active channel subscriptions are re-established  
2. The app invalidates stale queries to catch up on missed events  
3. The connection banner disappears

```ts
// On reconnection: refresh stale data
function useReconnectionRefresh() {
  const queryClient = useQueryClient();
  const prevState = useRef<ConnectionState>("connected");
  const state = useConnectionState();

  useEffect(() => {
    // Detect transition from disconnected → connected
    if (prevState.current !== "connected" && state === "connected") {
      // Invalidate all active queries to catch up
      queryClient.invalidateQueries();
    }
    prevState.current = state;
  }, [state]);
}
```

---

## 17.12 Supabase Realtime Configuration 

### Required Realtime Setup 

Supabase Realtime must be enabled for the following tables:

| Table | Events | Filter Support |
| :---- | :---- | :---- |
| `messages` | INSERT, UPDATE | `chat_id` |
| `chats` | INSERT, UPDATE, DELETE | `user_id`, `id` |
| `notifications` | INSERT | `user_id` |
| `personas` | UPDATE | `user_id` |
| `instruction_memory` | INSERT | `user_id` |
| `activity_log` | INSERT | `user_id` |
| `chat_participants` | INSERT, UPDATE, DELETE | `chat_id` |
| `files` | INSERT | `instance_id` |
| `instances` | UPDATE | `id` |
| `instance_personas` | INSERT, UPDATE, DELETE | `instance_id` |

### Enabling Realtime in Supabase 

```sql
-- Enable Realtime for required tables
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
ALTER PUBLICATION supabase_realtime ADD TABLE chats;
ALTER PUBLICATION supabase_realtime ADD TABLE notifications;
ALTER PUBLICATION supabase_realtime ADD TABLE personas;
ALTER PUBLICATION supabase_realtime ADD TABLE instruction_memory;
ALTER PUBLICATION supabase_realtime ADD TABLE activity_log;
ALTER PUBLICATION supabase_realtime ADD TABLE chat_participants;
ALTER PUBLICATION supabase_realtime ADD TABLE files;
ALTER PUBLICATION supabase_realtime ADD TABLE instances;
ALTER PUBLICATION supabase_realtime ADD TABLE instance_personas;
```

### Realtime RLS 

Supabase Realtime respects Row Level Security. Only rows that the user is authorized to read (via RLS SELECT policies) will trigger events on their channel. This means:

- Users only receive message events for chats they own  
- Users only receive notification events for their own notifications  
- Users only receive persona updates for personas they created

No additional authorization logic is needed in the Realtime layer — RLS policies from Parts 2–16 handle all access control.

### Channel Limits 

| Limit | Value | Notes |
| :---- | :---- | :---- |
| Max channels per connection | 100 | Sufficient for all aiConnected screens |
| Max connections per user | 10 | Covers multiple tabs/devices |
| Broadcast message size | 1 MB | More than sufficient for typing/status events |
| postgres\_changes filter depth | 3 | Allows compound filters (table \+ column \+ value) |

---

## 17.13 Presence (Future — v2) 

Presence tracking enables showing which personas are "active" in an instance or chat, and eventually which human team members are online (enterprise). This section documents the target architecture for v2.

### Persona Activity Presence 

In v1, persona activity is inferred from the `personas.mood` field (updated daily by `cipher-health`, Part 14). In v2, presence would be real-time:

```ts
// Future: real-time persona presence per instance
function useInstancePresence(instanceId: string) {
  const supabase = useSupabaseClient();
  const [activePersonas, setActivePersonas] = useState<string[]>([]);

  useEffect(() => {
    const channel = supabase.channel(`instance:${instanceId}:presence`);

    channel
      .on("presence", { event: "sync" }, () => {
        const state = channel.presenceState();
        const personaIds = Object.values(state)
          .flat()
          .map((p: any) => p.persona_id)
          .filter(Boolean);
        setActivePersonas([...new Set(personaIds)]);
      })
      .subscribe(async (status) => {
        if (status === "SUBSCRIBED") {
          // Edge Functions would track presence for active personas
          // This is managed server-side, not by the client
        }
      });

    return () => {
      supabase.removeChannel(channel);
    };
  }, [instanceId]);

  return activePersonas;
}
```

### Human Team Presence (Enterprise) 

In a future multi-user enterprise mode, human team members would track their own presence:

```ts
// Future: human team member presence
function useTeamPresence(instanceId: string) {
  const supabase = useSupabaseClient();
  const { session } = useAuth();
  const [onlineMembers, setOnlineMembers] = useState<PresenceMember[]>([]);

  useEffect(() => {
    const channel = supabase.channel(`instance:${instanceId}:team`);

    channel
      .on("presence", { event: "sync" }, () => {
        const state = channel.presenceState();
        const members: PresenceMember[] = Object.values(state)
          .flat()
          .map((p: any) => ({
            userId: p.user_id,
            displayName: p.display_name,
            avatarUrl: p.avatar_url,
            lastSeen: p.last_seen,
          }));
        setOnlineMembers(members);
      })
      .subscribe(async (status) => {
        if (status === "SUBSCRIBED") {
          await channel.track({
            user_id: session.user.id,
            display_name: profile.display_name,
            avatar_url: profile.avatar_url,
            last_seen: new Date().toISOString(),
          });
        }
      });

    return () => {
      channel.untrack();
      supabase.removeChannel(channel);
    };
  }, [instanceId]);

  return onlineMembers;
}

interface PresenceMember {
  userId: string;
  displayName: string;
  avatarUrl: string | null;
  lastSeen: string;
}
```

### Presence UI (v2) 

Active personas and online team members would be shown as avatar stacks in the instance header:

```ts
// Instance header — presence avatars (v2)
{
  display: "flex",
  alignItems: "center",
  gap: -4, // Overlapping avatars
}
// Each avatar: 24px circle, border: 2px solid theme.surface
// Tooltip on hover: "{name} — active" or "{name} — online"
// Overflow: "+3 more" chip after 5 avatars
```

This is scoped for v2 and not included in the v1 build. V1 uses static persona moods and status fields from the `personas` table.

---

## 17.14 Performance Considerations 

### Subscription Cleanup 

Every `useEffect` that creates a channel must clean it up on unmount. Failure to do so causes WebSocket connection leaks:

```ts
// Always return a cleanup function
useEffect(() => {
  const channel = supabase.channel("...");
  channel.subscribe();
  return () => supabase.removeChannel(channel);
}, [dependency]);
```

### Debouncing Cache Updates 

Rapid Realtime events (e.g., during team execution where multiple personas respond quickly) can trigger excessive React re-renders. Batch updates using `queryClient.setQueryData` with a debounce:

```ts
// For high-frequency events, batch updates
const pendingUpdates = useRef<Message[]>([]);
const flushTimeout = useRef<NodeJS.Timeout | null>(null);

function batchNewMessage(message: Message) {
  pendingUpdates.current.push(message);

  if (flushTimeout.current) clearTimeout(flushTimeout.current);
  flushTimeout.current = setTimeout(() => {
    const batch = [...pendingUpdates.current];
    pendingUpdates.current = [];

    queryClient.setQueryData<Message[]>(
      ["messages", chatId],
      (old) => {
        if (!old) return batch;
        const existingIds = new Set(old.map(m => m.id));
        const newMessages = batch.filter(m => !existingIds.has(m.id));
        return [...old, ...newMessages];
      }
    );
  }, 50); // 50ms batch window
}
```

### Channel Deduplication 

Prevent duplicate subscriptions when components remount:

```ts
// Track active channels to prevent duplicates
const activeChannels = new Set<string>();

function safeSubscribe(channelName: string, setup: (channel: any) => any) {
  if (activeChannels.has(channelName)) return null;
  activeChannels.add(channelName);

  const channel = setup(supabase.channel(channelName));
  channel.subscribe();

  return () => {
    activeChannels.delete(channelName);
    supabase.removeChannel(channel);
  };
}
```

### Payload Size Management 

Supabase Realtime sends the full row payload by default. For large tables (like `messages` with long `content`), this can be wasteful. Mitigations:

1. Use `columns` filter in the subscription to limit which columns trigger events (Supabase supports this for `postgres_changes`)  
2. For bulk operations, invalidate the query instead of processing individual payloads  
3. For the `messages` table, the full payload is necessary (we need the content), so no filtering is applied

### Latency Targets 

| Event Type | Target E2E Latency | Notes |
| :---- | :---- | :---- |
| Typing indicator | \< 100ms | Broadcast (no database, minimal routing) |
| New message (Realtime) | \< 300ms | postgres\_changes (WAL → Realtime → WebSocket) |
| Notification | \< 500ms | postgres\_changes \+ toast render |
| Chat list reorder | \< 200ms | Optimistic \+ Realtime confirmation |
| SSE first token | \< 2s | Includes routing \+ model cold start |

---

## 17.15 Cross-Reference Summary 

| Feature | Defined In | Real-Time Component |
| :---- | :---- | :---- |
| Message streaming (SSE) | Parts 5, 14, 16 | `cipher-route` Edge Function → SSE |
| Typing indicators | Part 17 (this part) | Broadcast on `chat:{id}` |
| Auto-rename banner | Parts 5, 14, 17 | postgres\_changes on `chats` |
| Suggested move banner | Parts 14, 17 | postgres\_changes on `chats.metadata` |
| Notification delivery | Parts 3, 17 | postgres\_changes on `notifications` |
| Activity feed updates | Parts 4, 17 | postgres\_changes on `activity_log` |
| Instruction learned toast | Parts 13, 14, 17 | postgres\_changes on `instruction_memory` |
| Chat list reordering | Parts 5, 17 | postgres\_changes on `chats` |
| Persona mood/status | Parts 9, 14, 17 | postgres\_changes on `personas` |
| Instance file uploads | Parts 8, 17 | postgres\_changes on `files` |
| Browser navigation | Parts 11, 17 | Broadcast on `browser:{id}` |
| Toast system | Part 17 (this part) | Client-side state (not Realtime) |
| Optimistic updates | Part 17 (this part) | Client-side cache manipulation |
| Connection monitoring | Part 17 (this part) | WebSocket state tracking |
| Presence (v2) | Part 17 (this part) | Supabase Presence channels |

---

*End of Part 17\. Proceed to Part 18: Supabase Database Schema (Complete).*

# PART 18: SUPABASE DATABASE SCHEMA (Complete) 

---

This part consolidates every database object defined across Parts 1–17 into a single authoritative schema reference. It includes all tables, columns, constraints, indexes, Row Level Security (RLS) policies, triggers, functions, and storage buckets. Where earlier parts defined a table, this part reproduces the canonical version with any schema extensions applied in later parts (e.g., Part 15 extending `persona_memories` with `layer` and `expires_at` columns).

This document is organized by domain. Each domain section lists tables in dependency order — referenced tables appear before tables that reference them.

**Conventions:**

- All tables live in the `public` schema  
- All primary keys are `UUID` using `gen_random_uuid()`  
- All timestamps are `TIMESTAMPTZ` defaulting to `now()`  
- All tables have RLS enabled  
- `handle_updated_at()` is the shared trigger function for `updated_at` columns  
- `auth.uid()` refers to the Supabase Auth user ID  
- `auth.role()` returns the current role (`'authenticated'`, `'anon'`, `'service_role'`)

**Table count:** 35 tables **Function count:** 30 RPC functions **Trigger count:** 20 triggers **Storage buckets:** 4

---

## 18.1 Prerequisites & Extensions 

```sql
-- Required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";      -- UUID generation (built-in via gen_random_uuid)
CREATE EXTENSION IF NOT EXISTS "pgvector";        -- Vector embeddings for semantic search
CREATE EXTENSION IF NOT EXISTS "pg_cron";         -- Scheduled background jobs
CREATE EXTENSION IF NOT EXISTS "pg_trgm";         -- Trigram similarity for fuzzy search

-- Shared utility function: auto-update updated_at
-- Source: Part 2
CREATE OR REPLACE FUNCTION public.handle_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
```

---

## 18.2 Domain 1: User & Account 

### profiles 

The root user table. Every authenticated user has exactly one profile row, auto-created on signup.

| Source | Part 2 |
| :---- | :---- |

```sql
CREATE TABLE public.profiles (
  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  display_name TEXT NOT NULL DEFAULT '',
  avatar_url TEXT,
  tier TEXT NOT NULL DEFAULT 'free'
    CHECK (tier IN ('free', 'plus', 'premium', 'pro')),
  preferences JSONB NOT NULL DEFAULT '{
    "theme": "system",
    "interface_mode": "standard",
    "default_model": null,
    "show_routing_notes": true,
    "show_memory_extraction": true,
    "notifications": {
      "persona_updates": true,
      "system_alerts": true,
      "chat_mentions": true,
      "weekly_summary": true
    },
    "voice_tone": "professional",
    "auto_rename": true,
    "suggest_moves": true,
    "scheduled_cleanup": false,
    "cleanup_frequency": "daily"
  }'::jsonb,
  onboarding_completed BOOLEAN NOT NULL DEFAULT false,
  credits INTEGER NOT NULL DEFAULT 100,
  storage_used_bytes BIGINT NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view own profile"
  ON public.profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
  ON public.profiles FOR UPDATE USING (auth.uid() = id);

CREATE INDEX idx_profiles_tier ON public.profiles(tier);

CREATE TRIGGER set_profiles_updated_at
  BEFORE UPDATE ON public.profiles
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

**Auto-creation trigger:**

```sql
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.profiles (id, display_name, avatar_url)
  VALUES (
    NEW.id,
    COALESCE(
      NEW.raw_user_meta_data ->> 'display_name',
      NEW.raw_user_meta_data ->> 'full_name',
      NEW.raw_user_meta_data ->> 'name',
      ''
    ),
    COALESCE(NEW.raw_user_meta_data ->> 'avatar_url', NULL)
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
```

### subscriptions 

Stripe subscription state. One row per user. Only service role can write (via Stripe webhook).

| Source | Part 2 |
| :---- | :---- |

```sql
CREATE TABLE public.subscriptions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL UNIQUE REFERENCES public.profiles(id) ON DELETE CASCADE,
  stripe_customer_id TEXT NOT NULL,
  stripe_subscription_id TEXT,
  tier TEXT NOT NULL DEFAULT 'free'
    CHECK (tier IN ('free', 'plus', 'premium', 'pro')),
  status TEXT NOT NULL DEFAULT 'active'
    CHECK (status IN ('active', 'canceled', 'past_due', 'trialing')),
  current_period_start TIMESTAMPTZ,
  current_period_end TIMESTAMPTZ,
  cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own subscription"
  ON public.subscriptions FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Service role manages subscriptions"
  ON public.subscriptions FOR ALL USING (auth.role() = 'service_role');

CREATE INDEX idx_subscriptions_user ON public.subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe ON public.subscriptions(stripe_customer_id);
```

### api\_keys 

Encrypted API keys for BYOK. Part 2 defined `openrouter` only; Part 13 expanded the UI to accept `anthropic`, `openai`, and `google` providers.

| Source | Part 2, extended by Part 13 |
| :---- | :---- |

```sql
CREATE TABLE public.api_keys (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  provider TEXT NOT NULL DEFAULT 'openrouter'
    CHECK (provider IN ('openrouter', 'anthropic', 'openai', 'google')),
  encrypted_key TEXT NOT NULL,
  key_hint TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'active'
    CHECK (status IN ('active', 'invalid', 'revoked')),
  last_validated_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.api_keys ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own API keys"
  ON public.api_keys FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_api_keys_user ON public.api_keys(user_id);
CREATE INDEX idx_api_keys_provider ON public.api_keys(user_id, provider, status);
```

### credit\_transactions 

Pay-as-you-go credit ledger for free-tier users.

| Source | Part 2 |
| :---- | :---- |

```sql
CREATE TABLE public.credit_transactions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  amount INTEGER NOT NULL,
  balance_after INTEGER NOT NULL,
  description TEXT NOT NULL,
  stripe_payment_id TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.credit_transactions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own transactions"
  ON public.credit_transactions FOR SELECT USING (auth.uid() = user_id);

CREATE INDEX idx_credit_transactions_user ON public.credit_transactions(user_id);
CREATE INDEX idx_credit_transactions_created ON public.credit_transactions(created_at DESC);
```

---

## 18.3 Domain 2: Chat System 

### chats 

Conversations between users and personas. Optionally scoped to an Instance.

| Source | Part 5 |
| :---- | :---- |

```sql
CREATE TABLE public.chats (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  title TEXT NOT NULL DEFAULT 'New Chat',
  auto_title TEXT,
  instance_id UUID REFERENCES public.instances(id) ON DELETE SET NULL,
  pinned BOOLEAN NOT NULL DEFAULT false,
  archived BOOLEAN NOT NULL DEFAULT false,
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at TIMESTAMPTZ
);

ALTER TABLE public.chats ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own chats"
  ON public.chats FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_chats_user_updated ON public.chats(user_id, updated_at DESC);
CREATE INDEX idx_chats_instance ON public.chats(instance_id) WHERE instance_id IS NOT NULL;
CREATE INDEX idx_chats_deleted ON public.chats(user_id, deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_chats_fulltext ON public.chats USING gin(to_tsvector('english', title));

CREATE TRIGGER set_chats_updated_at
  BEFORE UPDATE ON public.chats
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### chat\_participants 

Links personas to chats. Supports soft-removal (removed\_at) for participant history.

| Source | Part 5 |
| :---- | :---- |

```sql
CREATE TABLE public.chat_participants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  chat_id UUID NOT NULL REFERENCES public.chats(id) ON DELETE CASCADE,
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  removed_at TIMESTAMPTZ,
  is_primary BOOLEAN NOT NULL DEFAULT false,
  UNIQUE(chat_id, persona_id)
);

ALTER TABLE public.chat_participants ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage participants in own chats"
  ON public.chat_participants FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.chats
      WHERE chats.id = chat_participants.chat_id
      AND chats.user_id = auth.uid()
    )
  );

CREATE INDEX idx_chat_participants_chat ON public.chat_participants(chat_id) WHERE removed_at IS NULL;
CREATE INDEX idx_chat_participants_persona ON public.chat_participants(persona_id) WHERE removed_at IS NULL;
```

### messages 

All chat messages: user, persona, system, and cipher types.

| Source | Part 5 |
| :---- | :---- |

```sql
CREATE TABLE public.messages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  chat_id UUID NOT NULL REFERENCES public.chats(id) ON DELETE CASCADE,
  sender_type TEXT NOT NULL
    CHECK (sender_type IN ('user', 'persona', 'system', 'cipher')),
  sender_id UUID,
  sender_name TEXT NOT NULL DEFAULT '',
  content TEXT NOT NULL DEFAULT '',
  content_blocks JSONB DEFAULT '[]'::jsonb,
  model_used TEXT,
  routing_reason TEXT,
  token_count INTEGER,
  latency_ms INTEGER,
  pinned BOOLEAN NOT NULL DEFAULT false,
  has_link BOOLEAN NOT NULL DEFAULT false,
  has_media BOOLEAN NOT NULL DEFAULT false,
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at TIMESTAMPTZ
);

ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view messages in own chats"
  ON public.messages FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM public.chats
      WHERE chats.id = messages.chat_id
      AND chats.user_id = auth.uid()
    )
  );

CREATE POLICY "Users insert messages in own chats"
  ON public.messages FOR INSERT
  WITH CHECK (
    EXISTS (
      SELECT 1 FROM public.chats
      WHERE chats.id = messages.chat_id
      AND chats.user_id = auth.uid()
    )
  );

CREATE INDEX idx_messages_chat_created ON public.messages(chat_id, created_at);
CREATE INDEX idx_messages_pinned ON public.messages(chat_id) WHERE pinned = true;
CREATE INDEX idx_messages_fulltext ON public.messages USING gin(to_tsvector('english', content));
```

### export\_history 

Tracks chat export requests and their status.

| Source | Part 5 |
| :---- | :---- |

```sql
CREATE TABLE public.export_history (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  format TEXT NOT NULL CHECK (format IN ('markdown', 'json', 'pdf', 'html', 'zip')),
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
  file_path TEXT,
  file_size_bytes BIGINT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.export_history ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own exports"
  ON public.export_history FOR SELECT USING (auth.uid() = user_id);
```

---

## 18.4 Domain 3: Spaces & Instances 

### instances 

Workspaces that group chats, personas, files, and tasks around a purpose.

| Source | Part 7 |
| :---- | :---- |

```sql
CREATE TABLE public.instances (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  type TEXT NOT NULL DEFAULT 'general',
  icon TEXT NOT NULL DEFAULT 'Folder',
  color TEXT NOT NULL DEFAULT '#2e95f3',
  pinned BOOLEAN NOT NULL DEFAULT false,
  archived BOOLEAN NOT NULL DEFAULT false,
  settings JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at TIMESTAMPTZ
);

ALTER TABLE public.instances ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own instances"
  ON public.instances FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_instances_user ON public.instances(user_id, updated_at DESC);
CREATE INDEX idx_instances_type ON public.instances(user_id, type);

CREATE TRIGGER set_instances_updated_at
  BEFORE UPDATE ON public.instances
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### instance\_personas 

Many-to-many join between instances and personas.

| Source | Part 7 |
| :---- | :---- |

```sql
CREATE TABLE public.instance_personas (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  instance_id UUID NOT NULL REFERENCES public.instances(id) ON DELETE CASCADE,
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  role TEXT NOT NULL DEFAULT 'participant'
    CHECK (role IN ('primary', 'participant')),
  added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(instance_id, persona_id)
);

ALTER TABLE public.instance_personas ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage personas in own instances"
  ON public.instance_personas FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.instances
      WHERE instances.id = instance_personas.instance_id
      AND instances.user_id = auth.uid()
    )
  );

CREATE INDEX idx_instance_personas_instance ON public.instance_personas(instance_id);
CREATE INDEX idx_instance_personas_persona ON public.instance_personas(persona_id);
```

### instance\_types 

Reusable templates providing default settings for instances.

| Source | Part 13 |
| :---- | :---- |

```sql
CREATE TABLE public.instance_types (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  icon TEXT NOT NULL DEFAULT 'Folder',
  default_settings JSONB NOT NULL DEFAULT '{}'::jsonb,
  is_builtin BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(user_id, name)
);

ALTER TABLE public.instance_types ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own instance types"
  ON public.instance_types FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_instance_types_user ON public.instance_types(user_id);

CREATE TRIGGER set_instance_types_updated_at
  BEFORE UPDATE ON public.instance_types
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### tasks 

Instance-scoped tasks (kanban cards).

| Source | Part 7 |
| :---- | :---- |

```sql
CREATE TABLE public.tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  instance_id UUID NOT NULL REFERENCES public.instances(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  status TEXT NOT NULL DEFAULT 'todo'
    CHECK (status IN ('todo', 'in_progress', 'done', 'blocked')),
  priority TEXT NOT NULL DEFAULT 'medium'
    CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
  assigned_persona_id UUID REFERENCES public.personas(id) ON DELETE SET NULL,
  source_chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  source_message_id UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  due_date DATE,
  sort_order INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage tasks in own instances"
  ON public.tasks FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_tasks_instance ON public.tasks(instance_id, status, sort_order);
CREATE INDEX idx_tasks_assigned ON public.tasks(assigned_persona_id) WHERE assigned_persona_id IS NOT NULL;

CREATE TRIGGER set_tasks_updated_at
  BEFORE UPDATE ON public.tasks
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### workspace\_items 

Flexible collection linking various content types to instances (files, links, notes).

| Source | Part 7 |
| :---- | :---- |

```sql
CREATE TABLE public.workspace_items (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  instance_id UUID NOT NULL REFERENCES public.instances(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  type TEXT NOT NULL CHECK (type IN ('file', 'link', 'note', 'chat_ref')),
  title TEXT NOT NULL DEFAULT '',
  content TEXT,
  url TEXT,
  file_id UUID REFERENCES public.files(id) ON DELETE SET NULL,
  chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  sort_order INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.workspace_items ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage workspace items in own instances"
  ON public.workspace_items FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_workspace_items_instance ON public.workspace_items(instance_id, sort_order);
```

---

## 18.5 Domain 4: Files 

### files 

Global file system entries. Tracks both metadata and storage bucket references.

| Source | Part 8 |
| :---- | :---- |

```sql
CREATE TABLE public.files (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
  size_bytes BIGINT NOT NULL DEFAULT 0,
  storage_path TEXT NOT NULL,
  folder TEXT NOT NULL DEFAULT '/',
  instance_id UUID REFERENCES public.instances(id) ON DELETE SET NULL,
  chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  message_id UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  description TEXT,
  tags TEXT[] DEFAULT '{}',
  pinned BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at TIMESTAMPTZ
);

ALTER TABLE public.files ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own files"
  ON public.files FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_files_user ON public.files(user_id, updated_at DESC);
CREATE INDEX idx_files_folder ON public.files(user_id, folder);
CREATE INDEX idx_files_instance ON public.files(instance_id) WHERE instance_id IS NOT NULL;
CREATE INDEX idx_files_chat ON public.files(chat_id) WHERE chat_id IS NOT NULL;
CREATE INDEX idx_files_deleted ON public.files(user_id) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_files_fulltext ON public.files USING gin(to_tsvector('english', name));

CREATE TRIGGER set_files_updated_at
  BEFORE UPDATE ON public.files
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

---

## 18.6 Domain 5: Personas 

### personas 

AI persona definitions with identity, configuration, and status.

| Source | Part 9 |
| :---- | :---- |

```sql
CREATE TABLE public.personas (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT '',
  purpose TEXT NOT NULL DEFAULT '',
  personality TEXT NOT NULL DEFAULT '',
  voice TEXT NOT NULL DEFAULT '',
  avatar TEXT NOT NULL DEFAULT 'Bot',
  color TEXT NOT NULL DEFAULT '#2e95f3',
  status TEXT NOT NULL DEFAULT 'active'
    CHECK (status IN ('active', 'idle', 'archived')),
  mood TEXT,
  settings JSONB NOT NULL DEFAULT '{}'::jsonb,
  is_default BOOLEAN NOT NULL DEFAULT false,
  created_from_template UUID REFERENCES public.persona_templates(id) ON DELETE SET NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.personas ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own personas"
  ON public.personas FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_personas_user ON public.personas(user_id, status);
CREATE INDEX idx_personas_default ON public.personas(user_id) WHERE is_default = true;

CREATE TRIGGER set_personas_updated_at
  BEFORE UPDATE ON public.personas
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### persona\_skills 

Declared capabilities for each persona with optional capacity limits.

| Source | Part 9 |
| :---- | :---- |

```sql
CREATE TABLE public.persona_skills (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  category TEXT NOT NULL DEFAULT 'general'
    CHECK (category IN ('coding', 'creative', 'research', 'writing', 'planning', 'reasoning', 'general')),
  level TEXT NOT NULL DEFAULT 'intermediate'
    CHECK (level IN ('beginner', 'intermediate', 'advanced', 'expert')),
  description TEXT,
  temporary BOOLEAN NOT NULL DEFAULT false,
  expires_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(persona_id, name)
);

ALTER TABLE public.persona_skills ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage skills on own personas"
  ON public.persona_skills FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.personas
      WHERE personas.id = persona_skills.persona_id
      AND personas.user_id = auth.uid()
    )
  );

CREATE INDEX idx_skills_persona ON public.persona_skills(persona_id);
CREATE INDEX idx_skills_category ON public.persona_skills(persona_id, category);
CREATE INDEX idx_skills_temporary ON public.persona_skills(expires_at) WHERE temporary = true;
```

### persona\_boundaries 

Will-do, won't-do, and escalation rules that constrain persona behavior.

| Source | Part 9 |
| :---- | :---- |

```sql
CREATE TABLE public.persona_boundaries (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  type TEXT NOT NULL CHECK (type IN ('will_do', 'wont_do', 'escalation')),
  description TEXT NOT NULL,
  sort_order INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.persona_boundaries ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage boundaries on own personas"
  ON public.persona_boundaries FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.personas
      WHERE personas.id = persona_boundaries.persona_id
      AND personas.user_id = auth.uid()
    )
  );

CREATE INDEX idx_boundaries_persona ON public.persona_boundaries(persona_id, type);
```

### persona\_memories 

Knowledge nodes extracted from conversations. Extended by Part 15 with lifecycle columns.

| Source | Part 9, extended by Part 15 |
| :---- | :---- |

```sql
CREATE TABLE public.persona_memories (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  type TEXT NOT NULL
    CHECK (type IN ('decision', 'fact', 'preference', 'skill', 'procedure')),
  content TEXT NOT NULL,
  source_chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  source_message_id UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  confidence TEXT NOT NULL DEFAULT 'medium'
    CHECK (confidence IN ('strong', 'medium', 'weak')),
  tags TEXT[] DEFAULT '{}',
  active BOOLEAN NOT NULL DEFAULT true,
  embedding vector(1536),
  layer TEXT NOT NULL DEFAULT 'open'
    CHECK (layer IN ('open', 'closed')),
  expires_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.persona_memories ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage memories on own personas"
  ON public.persona_memories FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.personas
      WHERE personas.id = persona_memories.persona_id
      AND personas.user_id = auth.uid()
    )
  );

CREATE INDEX idx_memories_persona ON public.persona_memories(persona_id, created_at DESC);
CREATE INDEX idx_memories_type ON public.persona_memories(persona_id, type);
CREATE INDEX idx_memories_active ON public.persona_memories(persona_id) WHERE active = true;
CREATE INDEX idx_memories_layer ON public.persona_memories(persona_id, layer) WHERE active = true;
CREATE INDEX idx_memories_expiring ON public.persona_memories(expires_at) WHERE expires_at IS NOT NULL AND active = true;
CREATE INDEX idx_memories_embedding ON public.persona_memories
  USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
```

### persona\_health\_snapshots 

Daily health metrics for the persona Health tab and drift tracking.

| Source | Part 9 |
| :---- | :---- |

```sql
CREATE TABLE public.persona_health_snapshots (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  memory_stability NUMERIC(5,2),
  skill_stability NUMERIC(5,2),
  mood TEXT,
  drift_level TEXT CHECK (drift_level IN ('low', 'medium', 'high')),
  snapshot_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_health_persona ON public.persona_health_snapshots(persona_id, snapshot_at DESC);
```

### persona\_templates 

Pre-built persona configurations that users can instantiate.

| Source | Part 9 |
| :---- | :---- |

```sql
CREATE TABLE public.persona_templates (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  role TEXT NOT NULL DEFAULT '',
  purpose TEXT NOT NULL DEFAULT '',
  personality TEXT NOT NULL DEFAULT '',
  voice TEXT NOT NULL DEFAULT '',
  avatar TEXT NOT NULL DEFAULT 'Bot',
  color TEXT NOT NULL DEFAULT '#2e95f3',
  default_skills JSONB DEFAULT '[]'::jsonb,
  default_boundaries JSONB DEFAULT '[]'::jsonb,
  category TEXT NOT NULL DEFAULT 'general',
  is_builtin BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Templates are read-only for regular users
ALTER TABLE public.persona_templates ENABLE ROW LEVEL SECURITY;

CREATE POLICY "All authenticated users can read templates"
  ON public.persona_templates FOR SELECT
  USING (auth.role() = 'authenticated');

CREATE POLICY "Service role manages templates"
  ON public.persona_templates FOR ALL
  USING (auth.role() = 'service_role');

CREATE INDEX idx_templates_category ON public.persona_templates(category);
```

---

## 18.7 Domain 6: CogniGraph Memory System 

### memory\_edges 

Graph edges connecting memory nodes with typed relationships.

| Source | Part 15 |
| :---- | :---- |

```sql
CREATE TABLE public.memory_edges (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  from_node_id UUID NOT NULL REFERENCES public.persona_memories(id) ON DELETE CASCADE,
  to_node_id UUID NOT NULL REFERENCES public.persona_memories(id) ON DELETE CASCADE,
  relationship TEXT NOT NULL
    CHECK (relationship IN ('supports', 'contradicts', 'related', 'derived_from', 'supersedes')),
  strength NUMERIC(3,2) NOT NULL DEFAULT 0.50
    CHECK (strength >= 0 AND strength <= 1),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(from_node_id, to_node_id, relationship)
);

ALTER TABLE public.memory_edges ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage edges on own memories"
  ON public.memory_edges FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.persona_memories pm
      JOIN public.personas p ON p.id = pm.persona_id
      WHERE pm.id = memory_edges.from_node_id
      AND p.user_id = auth.uid()
    )
  );

CREATE INDEX idx_edges_from ON public.memory_edges(from_node_id);
CREATE INDEX idx_edges_to ON public.memory_edges(to_node_id);
CREATE INDEX idx_edges_relationship ON public.memory_edges(relationship);
```

### memory\_checkpoints 

Compressed summaries of conversation segments for context window efficiency.

| Source | Part 15 |
| :---- | :---- |

```sql
CREATE TABLE public.memory_checkpoints (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  chat_id UUID NOT NULL REFERENCES public.chats(id) ON DELETE CASCADE,
  summary TEXT NOT NULL,
  key_topics TEXT[] DEFAULT '{}',
  key_decisions TEXT[] DEFAULT '{}',
  message_range_start UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  message_range_end UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  message_count INTEGER NOT NULL DEFAULT 0,
  token_count INTEGER NOT NULL DEFAULT 0,
  checkpoint_type TEXT NOT NULL
    CHECK (checkpoint_type IN ('topic', 'time', 'token')),
  sequence_number INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.memory_checkpoints ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view checkpoints in own chats"
  ON public.memory_checkpoints FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM public.chats
      WHERE chats.id = memory_checkpoints.chat_id
      AND chats.user_id = auth.uid()
    )
  );
CREATE POLICY "Service role insert checkpoints"
  ON public.memory_checkpoints FOR INSERT
  USING (auth.role() = 'service_role');

CREATE INDEX idx_checkpoints_chat ON public.memory_checkpoints(chat_id, sequence_number);
CREATE INDEX idx_checkpoints_type ON public.memory_checkpoints(chat_id, checkpoint_type);
```

### instruction\_memory 

User-level behavioral rules detected from conversation patterns.

| Source | Part 13 |
| :---- | :---- |

```sql
CREATE TABLE public.instruction_memory (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  rule TEXT NOT NULL,
  scope TEXT NOT NULL DEFAULT 'global'
    CHECK (scope IN ('global', 'type', 'instance')),
  scope_target_id UUID,
  scope_target_name TEXT,
  source_chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  active BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.instruction_memory ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own instruction memory"
  ON public.instruction_memory FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_instruction_memory_user ON public.instruction_memory(user_id);
CREATE INDEX idx_instruction_memory_active ON public.instruction_memory(user_id, active);
CREATE INDEX idx_instruction_memory_scope ON public.instruction_memory(user_id, scope, scope_target_id);

CREATE TRIGGER set_instruction_memory_updated_at
  BEFORE UPDATE ON public.instruction_memory
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

---

## 18.8 Domain 7: Teams 

### teams 

Agentic team definitions with persona ensembles and execution strategies.

| Source | Part 10 |
| :---- | :---- |

```sql
CREATE TABLE public.teams (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  icon TEXT NOT NULL DEFAULT 'Users',
  color TEXT NOT NULL DEFAULT '#818cf8',
  strategy TEXT NOT NULL DEFAULT 'sequential'
    CHECK (strategy IN ('sequential', 'parallel', 'debate', 'review_chain', 'custom')),
  execution_config JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.teams ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own teams"
  ON public.teams FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_teams_user ON public.teams(user_id);

CREATE TRIGGER set_teams_updated_at
  BEFORE UPDATE ON public.teams
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### team\_members 

Personas assigned to teams with specific roles in the execution plan.

| Source | Part 10 |
| :---- | :---- |

```sql
CREATE TABLE public.team_members (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id UUID NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE,
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  role TEXT NOT NULL DEFAULT 'contributor'
    CHECK (role IN ('lead', 'contributor', 'reviewer', 'synthesizer')),
  execution_order INTEGER NOT NULL DEFAULT 0,
  config JSONB NOT NULL DEFAULT '{}'::jsonb,
  added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(team_id, persona_id)
);

ALTER TABLE public.team_members ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage members in own teams"
  ON public.team_members FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.teams
      WHERE teams.id = team_members.team_id
      AND teams.user_id = auth.uid()
    )
  );

CREATE INDEX idx_team_members_team ON public.team_members(team_id, execution_order);
CREATE INDEX idx_team_members_persona ON public.team_members(persona_id);
```

### team\_tasks 

Top-level task assignments that trigger team execution runs.

| Source | Part 10 |
| :---- | :---- |

```sql
CREATE TABLE public.team_tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id UUID NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  status TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
  input_data JSONB NOT NULL DEFAULT '{}'::jsonb,
  output_data JSONB,
  source_chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.team_tasks ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own team tasks"
  ON public.team_tasks FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_team_tasks_team ON public.team_tasks(team_id, status);
CREATE INDEX idx_team_tasks_user ON public.team_tasks(user_id, created_at DESC);

CREATE TRIGGER set_team_tasks_updated_at
  BEFORE UPDATE ON public.team_tasks
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### team\_runs 

Individual execution steps within a team task — one row per persona's contribution.

| Source | Part 10 |
| :---- | :---- |

```sql
CREATE TABLE public.team_runs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  task_id UUID NOT NULL REFERENCES public.team_tasks(id) ON DELETE CASCADE,
  team_member_id UUID NOT NULL REFERENCES public.team_members(id) ON DELETE CASCADE,
  persona_id UUID NOT NULL REFERENCES public.personas(id) ON DELETE CASCADE,
  step_number INTEGER NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped')),
  input_text TEXT,
  output_text TEXT,
  model_used TEXT,
  token_count INTEGER,
  latency_ms INTEGER,
  started_at TIMESTAMPTZ,
  completed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.team_runs ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view runs in own tasks"
  ON public.team_runs FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM public.team_tasks
      WHERE team_tasks.id = team_runs.task_id
      AND team_tasks.user_id = auth.uid()
    )
  );

CREATE INDEX idx_team_runs_task ON public.team_runs(task_id, step_number);
CREATE INDEX idx_team_runs_persona ON public.team_runs(persona_id);
```

---

## 18.9 Domain 8: Browser 

### browser\_sessions 

Active browser workspace sessions.

| Source | Part 11 |
| :---- | :---- |

```sql
CREATE TABLE public.browser_sessions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  name TEXT NOT NULL DEFAULT 'Browser Session',
  instance_id UUID REFERENCES public.instances(id) ON DELETE SET NULL,
  active_tab_id UUID,
  settings JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.browser_sessions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own browser sessions"
  ON public.browser_sessions FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_browser_sessions_user ON public.browser_sessions(user_id, updated_at DESC);

CREATE TRIGGER set_browser_sessions_updated_at
  BEFORE UPDATE ON public.browser_sessions
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### browser\_tabs 

Individual tabs within a browser session.

| Source | Part 11 |
| :---- | :---- |

```sql
CREATE TABLE public.browser_tabs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  session_id UUID NOT NULL REFERENCES public.browser_sessions(id) ON DELETE CASCADE,
  url TEXT NOT NULL DEFAULT '',
  title TEXT NOT NULL DEFAULT 'New Tab',
  favicon_url TEXT,
  status TEXT NOT NULL DEFAULT 'idle'
    CHECK (status IN ('idle', 'loading', 'loaded', 'error')),
  sort_order INTEGER NOT NULL DEFAULT 0,
  pinned BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.browser_tabs ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage tabs in own sessions"
  ON public.browser_tabs FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.browser_sessions
      WHERE browser_sessions.id = browser_tabs.session_id
      AND browser_sessions.user_id = auth.uid()
    )
  );

CREATE INDEX idx_browser_tabs_session ON public.browser_tabs(session_id, sort_order);

CREATE TRIGGER set_browser_tabs_updated_at
  BEFORE UPDATE ON public.browser_tabs
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### browser\_history 

Browsing history entries.

| Source | Part 11 |
| :---- | :---- |

```sql
CREATE TABLE public.browser_history (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  session_id UUID REFERENCES public.browser_sessions(id) ON DELETE SET NULL,
  url TEXT NOT NULL,
  title TEXT NOT NULL DEFAULT '',
  visited_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.browser_history ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own browser history"
  ON public.browser_history FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_browser_history_user ON public.browser_history(user_id, visited_at DESC);
CREATE INDEX idx_browser_history_url ON public.browser_history(user_id, url);
```

### browser\_extracts 

Saved page content extracted from the browser viewport.

| Source | Part 11 |
| :---- | :---- |

```sql
CREATE TABLE public.browser_extracts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  session_id UUID REFERENCES public.browser_sessions(id) ON DELETE SET NULL,
  tab_id UUID REFERENCES public.browser_tabs(id) ON DELETE SET NULL,
  url TEXT NOT NULL,
  title TEXT NOT NULL DEFAULT '',
  content TEXT NOT NULL,
  content_type TEXT NOT NULL DEFAULT 'text'
    CHECK (content_type IN ('text', 'markdown', 'html', 'screenshot')),
  storage_path TEXT,
  instance_id UUID REFERENCES public.instances(id) ON DELETE SET NULL,
  tags TEXT[] DEFAULT '{}',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.browser_extracts ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own extracts"
  ON public.browser_extracts FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_browser_extracts_user ON public.browser_extracts(user_id, created_at DESC);
CREATE INDEX idx_browser_extracts_instance ON public.browser_extracts(instance_id) WHERE instance_id IS NOT NULL;
CREATE INDEX idx_browser_extracts_fulltext ON public.browser_extracts USING gin(to_tsvector('english', content));
```

### browser\_highlights 

User-selected text highlights on web pages.

| Source | Part 11 |
| :---- | :---- |

```sql
CREATE TABLE public.browser_highlights (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  extract_id UUID REFERENCES public.browser_extracts(id) ON DELETE CASCADE,
  url TEXT NOT NULL,
  selected_text TEXT NOT NULL,
  context_before TEXT,
  context_after TEXT,
  color TEXT NOT NULL DEFAULT '#fbbf24',
  note TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.browser_highlights ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own highlights"
  ON public.browser_highlights FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_browser_highlights_user ON public.browser_highlights(user_id, created_at DESC);
CREATE INDEX idx_browser_highlights_url ON public.browser_highlights(url);
```

---

## 18.10 Domain 9: Search 

### search\_history 

Recent search queries for the universal search screen.

| Source | Part 6 |
| :---- | :---- |

```sql
CREATE TABLE public.search_history (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  query TEXT NOT NULL,
  result_count INTEGER NOT NULL DEFAULT 0,
  filters JSONB DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.search_history ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own search history"
  ON public.search_history FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_search_history_user ON public.search_history(user_id, created_at DESC);
```

### saved\_results 

User-saved search result items for later reference.

| Source | Part 6 |
| :---- | :---- |

```sql
CREATE TABLE public.saved_results (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  entity_type TEXT NOT NULL
    CHECK (entity_type IN ('chat', 'message', 'file', 'persona', 'instance', 'memory')),
  entity_id UUID NOT NULL,
  title TEXT NOT NULL DEFAULT '',
  snippet TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(user_id, entity_type, entity_id)
);

ALTER TABLE public.saved_results ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own saved results"
  ON public.saved_results FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_saved_results_user ON public.saved_results(user_id, created_at DESC);
```

---

## 18.11 Domain 10: AI Model Routing 

### model\_registry 

Platform-managed catalog of all available AI models.

| Source | Part 16 |
| :---- | :---- |

```sql
CREATE TABLE public.model_registry (
  id TEXT PRIMARY KEY,
  display_name TEXT NOT NULL,
  provider TEXT NOT NULL
    CHECK (provider IN ('anthropic', 'openai', 'google', 'deepseek', 'openrouter')),
  provider_model_id TEXT NOT NULL,
  openrouter_model_id TEXT NOT NULL,
  tier_required TEXT NOT NULL DEFAULT 'free'
    CHECK (tier_required IN ('free', 'plus', 'premium', 'pro')),
  byok_only BOOLEAN NOT NULL DEFAULT false,
  capabilities TEXT[] NOT NULL DEFAULT '{}',
  context_window INTEGER NOT NULL DEFAULT 128000,
  max_output_tokens INTEGER NOT NULL DEFAULT 4096,
  supports_streaming BOOLEAN NOT NULL DEFAULT true,
  supports_images BOOLEAN NOT NULL DEFAULT false,
  supports_tools BOOLEAN NOT NULL DEFAULT false,
  pricing_prompt NUMERIC(8,4) NOT NULL DEFAULT 0,
  pricing_completion NUMERIC(8,4) NOT NULL DEFAULT 0,
  status TEXT NOT NULL DEFAULT 'active'
    CHECK (status IN ('active', 'deprecated', 'disabled')),
  sort_order INTEGER NOT NULL DEFAULT 100,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.model_registry ENABLE ROW LEVEL SECURITY;

CREATE POLICY "All authenticated users can read"
  ON public.model_registry FOR SELECT USING (auth.role() = 'authenticated');
CREATE POLICY "Service role manages registry"
  ON public.model_registry FOR ALL USING (auth.role() = 'service_role');

CREATE INDEX idx_model_registry_provider ON public.model_registry(provider, sort_order);
CREATE INDEX idx_model_registry_status ON public.model_registry(status) WHERE status = 'active';

CREATE TRIGGER set_model_registry_updated_at
  BEFORE UPDATE ON public.model_registry
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### model\_role\_assignments 

User-specific model-to-role mappings consulted by Cipher during routing.

| Source | Part 13 |
| :---- | :---- |

```sql
CREATE TABLE public.model_role_assignments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  role TEXT NOT NULL,
  primary_model TEXT NOT NULL,
  fallback_model TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(user_id, role)
);

ALTER TABLE public.model_role_assignments ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own role assignments"
  ON public.model_role_assignments FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_model_roles_user ON public.model_role_assignments(user_id);

CREATE TRIGGER set_model_roles_updated_at
  BEFORE UPDATE ON public.model_role_assignments
  FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```

### token\_usage 

Per-call token consumption log for billing and analytics.

| Source | Part 16 |
| :---- | :---- |

```sql
CREATE TABLE public.token_usage (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  chat_id UUID REFERENCES public.chats(id) ON DELETE SET NULL,
  message_id UUID REFERENCES public.messages(id) ON DELETE SET NULL,
  model_id TEXT NOT NULL,
  provider_used TEXT NOT NULL,
  prompt_tokens INTEGER NOT NULL DEFAULT 0,
  completion_tokens INTEGER NOT NULL DEFAULT 0,
  total_tokens INTEGER NOT NULL DEFAULT 0,
  estimated_cost_usd NUMERIC(10,6) NOT NULL DEFAULT 0,
  is_byok BOOLEAN NOT NULL DEFAULT false,
  latency_ms INTEGER,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.token_usage ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own token usage"
  ON public.token_usage FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Service role insert usage"
  ON public.token_usage FOR INSERT USING (auth.role() = 'service_role');

CREATE INDEX idx_token_usage_user_created ON public.token_usage(user_id, created_at DESC);
CREATE INDEX idx_token_usage_model ON public.token_usage(model_id, created_at DESC);
CREATE INDEX idx_token_usage_chat ON public.token_usage(chat_id) WHERE chat_id IS NOT NULL;
```

### rate\_limit\_counters 

Windowed counters for per-user rate limiting.

| Source | Part 16 |
| :---- | :---- |

```sql
CREATE TABLE public.rate_limit_counters (
  id TEXT PRIMARY KEY,
  count INTEGER NOT NULL DEFAULT 0,
  window_start TIMESTAMPTZ NOT NULL DEFAULT now(),
  window_end TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_rate_limit_expiry ON public.rate_limit_counters(window_end);
```

---

## 18.12 Domain 11: System 

### notifications 

User-facing notification entries surfaced in the notification dropdown.

| Source | Part 3 |
| :---- | :---- |

```sql
CREATE TABLE public.notifications (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  type TEXT NOT NULL
    CHECK (type IN ('persona_update', 'system_alert', 'chat_mention', 'team_complete', 'memory_conflict', 'export_ready')),
  title TEXT NOT NULL,
  body TEXT,
  action_url TEXT,
  read BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own notifications"
  ON public.notifications FOR ALL USING (auth.uid() = user_id);

CREATE INDEX idx_notifications_user ON public.notifications(user_id, created_at DESC);
CREATE INDEX idx_notifications_unread ON public.notifications(user_id) WHERE read = false;
```

### activity\_log 

Platform-wide activity events for the home dashboard and analytics.

| Source | Part 4 |
| :---- | :---- |

```sql
CREATE TABLE public.activity_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
  actor_type TEXT NOT NULL
    CHECK (actor_type IN ('user', 'persona', 'system', 'team')),
  actor_id UUID,
  actor_name TEXT NOT NULL DEFAULT '',
  action TEXT NOT NULL,
  entity_type TEXT
    CHECK (entity_type IN ('chat', 'instance', 'persona', 'file', 'team', 'memory', 'setting')),
  entity_id UUID,
  entity_name TEXT,
  metadata JSONB DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.activity_log ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users view own activity"
  ON public.activity_log FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Service role inserts activity"
  ON public.activity_log FOR INSERT USING (auth.role() = 'service_role');

CREATE INDEX idx_activity_user ON public.activity_log(user_id, created_at DESC);
CREATE INDEX idx_activity_entity ON public.activity_log(entity_type, entity_id);
CREATE INDEX idx_activity_actor ON public.activity_log(actor_type, actor_id);
```

---

## 18.13 RPC Functions 

All server-side PostgreSQL functions referenced across the PRD. Organized by domain.

### Utility Functions 

```sql
-- Source: Part 2
CREATE OR REPLACE FUNCTION public.handle_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Source: Part 2
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.profiles (id, display_name, avatar_url)
  VALUES (
    NEW.id,
    COALESCE(NEW.raw_user_meta_data ->> 'display_name', NEW.raw_user_meta_data ->> 'full_name', NEW.raw_user_meta_data ->> 'name', ''),
    COALESCE(NEW.raw_user_meta_data ->> 'avatar_url', NULL)
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```

### Seed Functions 

```sql
-- Source: Part 13
CREATE OR REPLACE FUNCTION public.seed_model_roles()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.model_role_assignments (user_id, role, primary_model, fallback_model)
  VALUES
    (NEW.id, 'research', 'claude-sonnet-4-5-20250929', 'gpt-4o'),
    (NEW.id, 'writing', 'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001'),
    (NEW.id, 'coding', 'claude-sonnet-4-5-20250929', 'gpt-4o'),
    (NEW.id, 'creative', 'dall-e-3', 'claude-sonnet-4-5-20250929'),
    (NEW.id, 'planning', 'claude-opus-4-5-20250301', 'claude-sonnet-4-5-20250929'),
    (NEW.id, 'reasoning', 'claude-opus-4-5-20250301', 'deepseek-r1'),
    (NEW.id, 'quick', 'claude-haiku-4-5-20251001', 'gpt-4o-mini');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_profile_created_seed_roles
  AFTER INSERT ON public.profiles
  FOR EACH ROW EXECUTE FUNCTION public.seed_model_roles();

-- Source: Part 13
CREATE OR REPLACE FUNCTION public.seed_instance_types()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.instance_types (user_id, name, description, icon, is_builtin)
  VALUES
    (NEW.id, 'General', 'All-purpose workspace', 'Folder', true),
    (NEW.id, 'Project', 'Software or creative project', 'Code', true),
    (NEW.id, 'Research', 'Deep research and analysis', 'Search', true),
    (NEW.id, 'Client', 'Client relationship management', 'Users', true),
    (NEW.id, 'Learning', 'Study and education', 'BookOpen', true);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_profile_created_seed_types
  AFTER INSERT ON public.profiles
  FOR EACH ROW EXECUTE FUNCTION public.seed_instance_types();
```

### Search Functions 

```sql
-- Source: Part 6
CREATE OR REPLACE FUNCTION search_chat_titles(p_user_id UUID, p_query TEXT, p_limit INT DEFAULT 20)
RETURNS TABLE(id UUID, title TEXT, rank REAL) LANGUAGE sql STABLE AS $$
  SELECT c.id, c.title,
    ts_rank(to_tsvector('english', c.title), plainto_tsquery('english', p_query)) AS rank
  FROM chats c
  WHERE c.user_id = p_user_id AND c.deleted_at IS NULL
    AND to_tsvector('english', c.title) @@ plainto_tsquery('english', p_query)
  ORDER BY rank DESC LIMIT p_limit;
$$;

-- Source: Part 6
CREATE OR REPLACE FUNCTION search_message_content(p_user_id UUID, p_query TEXT, p_limit INT DEFAULT 20)
RETURNS TABLE(id UUID, chat_id UUID, content TEXT, sender_name TEXT, rank REAL) LANGUAGE sql STABLE AS $$
  SELECT m.id, m.chat_id, m.content, m.sender_name,
    ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', p_query)) AS rank
  FROM messages m JOIN chats c ON c.id = m.chat_id
  WHERE c.user_id = p_user_id AND m.deleted_at IS NULL AND c.deleted_at IS NULL
    AND to_tsvector('english', m.content) @@ plainto_tsquery('english', p_query)
  ORDER BY rank DESC LIMIT p_limit;
$$;

-- Source: Part 6
CREATE OR REPLACE FUNCTION search_files(p_user_id UUID, p_query TEXT, p_limit INT DEFAULT 20)
RETURNS TABLE(id UUID, name TEXT, folder TEXT, rank REAL) LANGUAGE sql STABLE AS $$
  SELECT f.id, f.name, f.folder,
    ts_rank(to_tsvector('english', f.name), plainto_tsquery('english', p_query)) AS rank
  FROM files f
  WHERE f.user_id = p_user_id AND f.deleted_at IS NULL
    AND to_tsvector('english', f.name) @@ plainto_tsquery('english', p_query)
  ORDER BY rank DESC LIMIT p_limit;
$$;

-- Source: Part 6
CREATE OR REPLACE FUNCTION search_memories_semantic(
  p_user_id UUID, p_embedding vector(1536), p_threshold FLOAT DEFAULT 0.5, p_limit INT DEFAULT 20
) RETURNS TABLE(id UUID, persona_id UUID, type TEXT, content TEXT, similarity FLOAT) LANGUAGE sql STABLE AS $$
  SELECT pm.id, pm.persona_id, pm.type, pm.content,
    1 - (pm.embedding <=> p_embedding) AS similarity
  FROM persona_memories pm JOIN personas p ON p.id = pm.persona_id
  WHERE p.user_id = p_user_id AND pm.active = true AND pm.embedding IS NOT NULL
    AND 1 - (pm.embedding <=> p_embedding) > p_threshold
  ORDER BY pm.embedding <=> p_embedding LIMIT p_limit;
$$;

-- Source: Part 6
CREATE OR REPLACE FUNCTION trim_search_history(p_user_id UUID, p_max INT DEFAULT 50)
RETURNS void LANGUAGE sql AS $$
  DELETE FROM search_history WHERE id IN (
    SELECT id FROM search_history WHERE user_id = p_user_id
    ORDER BY created_at DESC OFFSET p_max
  );
$$;
```

### Memory Functions 

```sql
-- Source: Part 14
CREATE OR REPLACE FUNCTION search_persona_memories(
  p_persona_id UUID, p_embedding vector(1536),
  p_match_threshold FLOAT DEFAULT 0.5, p_match_count INT DEFAULT 20
) RETURNS TABLE(id UUID, type TEXT, content TEXT, confidence TEXT, tags TEXT[], created_at TIMESTAMPTZ, similarity FLOAT)
LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY
  SELECT pm.id, pm.type, pm.content, pm.confidence, pm.tags, pm.created_at,
    1 - (pm.embedding <=> p_embedding) AS similarity
  FROM persona_memories pm
  WHERE pm.persona_id = p_persona_id AND pm.active = true AND pm.embedding IS NOT NULL
    AND 1 - (pm.embedding <=> p_embedding) > p_match_threshold
  ORDER BY pm.embedding <=> p_embedding LIMIT p_match_count;
END;
$$;

-- Source: Part 15
CREATE OR REPLACE FUNCTION search_instance_memories(
  p_instance_id UUID, p_exclude_persona_id UUID, p_embedding vector(1536),
  p_match_threshold FLOAT DEFAULT 0.6, p_match_count INT DEFAULT 10
) RETURNS TABLE(id UUID, persona_id UUID, type TEXT, content TEXT, confidence TEXT, layer TEXT, tags TEXT[], created_at TIMESTAMPTZ, similarity FLOAT)
LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY
  SELECT pm.id, pm.persona_id, pm.type, pm.content, pm.confidence, pm.layer, pm.tags, pm.created_at,
    1 - (pm.embedding <=> p_embedding) AS similarity
  FROM persona_memories pm
  JOIN personas p ON p.id = pm.persona_id
  JOIN instance_personas ip ON ip.persona_id = p.id
  WHERE ip.instance_id = p_instance_id AND pm.persona_id != p_exclude_persona_id
    AND pm.active = true AND pm.embedding IS NOT NULL
    AND 1 - (pm.embedding <=> p_embedding) > p_match_threshold
  ORDER BY pm.embedding <=> p_embedding LIMIT p_match_count;
END;
$$;

-- Source: Part 14
CREATE OR REPLACE FUNCTION persona_memory_stability(p_persona_id UUID)
RETURNS TABLE(stability NUMERIC, total_active BIGINT, conflicted BIGINT)
LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY
  SELECT
    CASE WHEN COUNT(*) = 0 THEN 100.0
    ELSE ROUND((1 - COUNT(*) FILTER (WHERE tags @> ARRAY['conflict'])::numeric / COUNT(*)) * 100, 2)
    END AS stability,
    COUNT(*) AS total_active,
    COUNT(*) FILTER (WHERE tags @> ARRAY['conflict']) AS conflicted
  FROM persona_memories WHERE persona_id = p_persona_id AND active = true;
END;
$$;

-- Source: Part 15
CREATE OR REPLACE FUNCTION cognigraph_stats(p_user_id UUID)
RETURNS TABLE(
  total_memories BIGINT, active_memories BIGINT, open_layer BIGINT, closed_layer BIGINT,
  by_type JSONB, conflict_count BIGINT, stale_count BIGINT, avg_confidence NUMERIC,
  total_edges BIGINT, total_checkpoints BIGINT
) LANGUAGE plpgsql AS $$
BEGIN
  RETURN QUERY
  WITH user_personas AS (SELECT id FROM personas WHERE user_id = p_user_id),
  mem_stats AS (
    SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE active) AS active,
      COUNT(*) FILTER (WHERE active AND layer = 'open') AS open_count,
      COUNT(*) FILTER (WHERE active AND layer = 'closed') AS closed_count,
      jsonb_build_object(
        'decision', COUNT(*) FILTER (WHERE active AND type = 'decision'),
        'fact', COUNT(*) FILTER (WHERE active AND type = 'fact'),
        'preference', COUNT(*) FILTER (WHERE active AND type = 'preference'),
        'skill', COUNT(*) FILTER (WHERE active AND type = 'skill'),
        'procedure', COUNT(*) FILTER (WHERE active AND type = 'procedure')
      ) AS type_counts,
      COUNT(*) FILTER (WHERE active AND tags @> ARRAY['conflict']) AS conflicts,
      COUNT(*) FILTER (WHERE active AND tags @> ARRAY['stale']) AS stale,
      AVG(CASE confidence WHEN 'strong' THEN 1.0 WHEN 'medium' THEN 0.66 WHEN 'weak' THEN 0.33 ELSE 0 END)
        FILTER (WHERE active) AS avg_conf
    FROM persona_memories WHERE persona_id IN (SELECT id FROM user_personas)
  ),
  edge_stats AS (
    SELECT COUNT(*) AS edge_count FROM memory_edges
    WHERE from_node_id IN (SELECT id FROM persona_memories WHERE persona_id IN (SELECT id FROM user_personas))
  ),
  checkpoint_stats AS (
    SELECT COUNT(*) AS cp_count FROM memory_checkpoints WHERE chat_id IN (SELECT id FROM chats WHERE user_id = p_user_id)
  )
  SELECT ms.total, ms.active, ms.open_count, ms.closed_count, ms.type_counts,
    ms.conflicts, ms.stale, ROUND(COALESCE(ms.avg_conf, 0), 2), es.edge_count, cs.cp_count
  FROM mem_stats ms, edge_stats es, checkpoint_stats cs;
END;
$$;
```

### Analytics Functions 

```sql
-- Source: Part 12
CREATE OR REPLACE FUNCTION analytics_conversation_stats(p_user_id UUID, p_interval TEXT)
RETURNS TABLE(total_chats BIGINT, total_messages BIGINT, avg_messages_per_chat NUMERIC) LANGUAGE sql STABLE AS $$
  SELECT COUNT(DISTINCT c.id), COUNT(m.id),
    ROUND(COUNT(m.id)::numeric / NULLIF(COUNT(DISTINCT c.id), 0), 1)
  FROM chats c LEFT JOIN messages m ON m.chat_id = c.id
  WHERE c.user_id = p_user_id AND c.deleted_at IS NULL
    AND c.created_at > now() - p_interval::interval;
$$;

-- Source: Part 12
CREATE OR REPLACE FUNCTION analytics_model_usage(p_user_id UUID, p_interval TEXT)
RETURNS TABLE(model TEXT, call_count BIGINT, usage_pct INTEGER, prompt_tokens BIGINT, completion_tokens BIGINT)
LANGUAGE sql STABLE AS $$
  SELECT m.model_used, COUNT(*),
    ROUND(COUNT(*)::numeric / NULLIF(SUM(COUNT(*)) OVER (), 0) * 100)::integer,
    COALESCE(SUM((m.metadata->>'prompt_tokens')::bigint), 0),
    COALESCE(SUM((m.metadata->>'completion_tokens')::bigint), 0)
  FROM messages m JOIN chats c ON c.id = m.chat_id
  WHERE c.user_id = p_user_id AND m.sender_type = 'persona' AND m.model_used IS NOT NULL
    AND m.created_at > now() - p_interval::interval AND m.deleted_at IS NULL
  GROUP BY m.model_used ORDER BY call_count DESC;
$$;

-- Source: Part 12
CREATE OR REPLACE FUNCTION analytics_memory_health(p_user_id UUID)
RETURNS TABLE(
  total_active BIGINT, by_type JSONB, conflict_count BIGINT,
  avg_confidence NUMERIC, memories_this_week BIGINT
) LANGUAGE sql STABLE AS $$
  SELECT
    COUNT(*) FILTER (WHERE active),
    jsonb_build_object(
      'decision', COUNT(*) FILTER (WHERE active AND type = 'decision'),
      'fact', COUNT(*) FILTER (WHERE active AND type = 'fact'),
      'preference', COUNT(*) FILTER (WHERE active AND type = 'preference'),
      'skill', COUNT(*) FILTER (WHERE active AND type = 'skill'),
      'procedure', COUNT(*) FILTER (WHERE active AND type = 'procedure')
    ),
    COUNT(*) FILTER (WHERE active AND tags @> ARRAY['conflict']),
    AVG(CASE confidence WHEN 'strong' THEN 1.0 WHEN 'medium' THEN 0.66 WHEN 'weak' THEN 0.33 END) FILTER (WHERE active),
    COUNT(*) FILTER (WHERE active AND created_at > now() - interval '7 days')
  FROM persona_memories pm JOIN personas p ON p.id = pm.persona_id
  WHERE p.user_id = p_user_id;
$$;

-- Source: Part 8
CREATE OR REPLACE FUNCTION get_storage_usage(p_user_id UUID)
RETURNS TABLE(total_bytes BIGINT, file_count BIGINT, by_type JSONB) LANGUAGE sql STABLE AS $$
  SELECT
    COALESCE(SUM(size_bytes), 0),
    COUNT(*),
    jsonb_object_agg(
      COALESCE(mime_type, 'unknown'),
      jsonb_build_object('count', type_count, 'bytes', type_bytes)
    )
  FROM (
    SELECT mime_type, COUNT(*) AS type_count, SUM(size_bytes) AS type_bytes
    FROM files WHERE user_id = p_user_id AND deleted_at IS NULL
    GROUP BY mime_type
  ) sub;
$$;
```

### Token & Model Health Functions 

```sql
-- Source: Part 16
CREATE OR REPLACE FUNCTION daily_token_usage(p_user_id UUID, p_days INTEGER DEFAULT 30)
RETURNS TABLE(usage_date DATE, model_id TEXT, total_prompt INTEGER, total_completion INTEGER, total_cost NUMERIC, call_count BIGINT)
LANGUAGE sql STABLE AS $$
  SELECT DATE(created_at), tu.model_id,
    COALESCE(SUM(prompt_tokens), 0)::integer, COALESCE(SUM(completion_tokens), 0)::integer,
    COALESCE(SUM(estimated_cost_usd), 0), COUNT(*)
  FROM token_usage tu WHERE tu.user_id = p_user_id
    AND tu.created_at > now() - (p_days || ' days')::interval
  GROUP BY DATE(created_at), tu.model_id ORDER BY usage_date DESC, total_cost DESC;
$$;

-- Source: Part 16
CREATE OR REPLACE FUNCTION billing_period_usage(p_user_id UUID, p_period_start TIMESTAMPTZ)
RETURNS TABLE(model_id TEXT, total_tokens BIGINT, total_cost NUMERIC, call_count BIGINT, is_byok BOOLEAN)
LANGUAGE sql STABLE AS $$
  SELECT tu.model_id, SUM(tu.total_tokens), SUM(tu.estimated_cost_usd), COUNT(*), tu.is_byok
  FROM token_usage tu WHERE tu.user_id = p_user_id AND tu.created_at >= p_period_start
  GROUP BY tu.model_id, tu.is_byok ORDER BY total_cost DESC;
$$;

-- Source: Part 16
CREATE OR REPLACE FUNCTION model_health_stats(p_user_id UUID, p_hours INTEGER DEFAULT 24)
RETURNS TABLE(model_id TEXT, call_count BIGINT, error_count BIGINT, avg_latency_ms NUMERIC, p95_latency_ms NUMERIC, success_rate NUMERIC, avg_completion_tokens NUMERIC)
LANGUAGE sql STABLE AS $$
  SELECT tu.model_id, COUNT(*),
    COUNT(*) FILTER (WHERE tu.latency_ms IS NULL OR tu.total_tokens = 0),
    AVG(tu.latency_ms) FILTER (WHERE tu.latency_ms IS NOT NULL),
    PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY tu.latency_ms) FILTER (WHERE tu.latency_ms IS NOT NULL),
    ROUND(COUNT(*) FILTER (WHERE tu.latency_ms IS NOT NULL AND tu.total_tokens > 0)::numeric / NULLIF(COUNT(*), 0) * 100, 1),
    AVG(tu.completion_tokens) FILTER (WHERE tu.completion_tokens > 0)
  FROM token_usage tu WHERE tu.user_id = p_user_id AND tu.created_at > now() - (p_hours || ' hours')::interval
  GROUP BY tu.model_id ORDER BY call_count DESC;
$$;
```

---

## 18.14 Scheduled Jobs (pg\_cron) 

```sql
-- Source: Part 14 — Cipher cleanup (hourly)
SELECT cron.schedule(
  'cipher-cleanup', '0 * * * *',
  $$SELECT net.http_post(
    url := current_setting('app.settings.supabase_url') || '/functions/v1/cipher-cleanup',
    headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'))
  )$$
);

-- Source: Part 14 — Cipher health (daily at 3 AM UTC)
SELECT cron.schedule(
  'cipher-health', '0 3 * * *',
  $$SELECT net.http_post(
    url := current_setting('app.settings.supabase_url') || '/functions/v1/cipher-health',
    headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'))
  )$$
);

-- Source: Part 16 — Rate limit counter cleanup (every 30 minutes)
SELECT cron.schedule(
  'rate-limit-cleanup', '30 * * * *',
  $$DELETE FROM public.rate_limit_counters WHERE window_end < now() - interval '1 hour'$$
);
```

---

## 18.15 Storage Buckets 

Supabase Storage buckets for file management.

### user-files 

Primary storage for user-uploaded files.

```sql
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
  'user-files', 'user-files', false, 52428800,  -- 50 MB limit per file
  ARRAY[
    'application/pdf', 'application/msword',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    'application/vnd.openxmlformats-officedocument.presentationml.presentation',
    'text/plain', 'text/csv', 'text/markdown',
    'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml',
    'audio/mpeg', 'audio/wav', 'video/mp4'
  ]
);

-- Paths follow: {user_id}/{file_uuid}/{filename}
CREATE POLICY "Users manage own files in storage"
  ON storage.objects FOR ALL
  USING (bucket_id = 'user-files' AND (storage.foldername(name))[1] = auth.uid()::text);
```

### persona-avatars 

Custom persona avatar images.

```sql
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
  'persona-avatars', 'persona-avatars', true, 5242880,  -- 5 MB limit
  ARRAY['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
);

CREATE POLICY "Users manage own persona avatars"
  ON storage.objects FOR ALL
  USING (bucket_id = 'persona-avatars' AND (storage.foldername(name))[1] = auth.uid()::text);

CREATE POLICY "Public read for persona avatars"
  ON storage.objects FOR SELECT
  USING (bucket_id = 'persona-avatars');
```

### exports 

Temporary storage for generated export files. Auto-cleaned after 24 hours.

```sql
INSERT INTO storage.buckets (id, name, public, file_size_limit)
VALUES ('exports', 'exports', false, 104857600);  -- 100 MB limit

CREATE POLICY "Users read own exports"
  ON storage.objects FOR SELECT
  USING (bucket_id = 'exports' AND (storage.foldername(name))[1] = auth.uid()::text);

CREATE POLICY "Service role writes exports"
  ON storage.objects FOR INSERT
  USING (bucket_id = 'exports' AND auth.role() = 'service_role');

-- Auto-cleanup: pg_cron job to delete export files older than 24 hours
SELECT cron.schedule(
  'export-cleanup', '0 */6 * * *',
  $$DELETE FROM storage.objects WHERE bucket_id = 'exports' AND created_at < now() - interval '24 hours'$$
);
```

### browser-extracts 

Saved page content and screenshots from the browser workspace.

```sql
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
  'browser-extracts', 'browser-extracts', false, 10485760,  -- 10 MB limit
  ARRAY['text/plain', 'text/html', 'text/markdown', 'image/png', 'image/jpeg']
);

CREATE POLICY "Users manage own browser extracts"
  ON storage.objects FOR ALL
  USING (bucket_id = 'browser-extracts' AND (storage.foldername(name))[1] = auth.uid()::text);
```

---

## 18.16 Entity Relationship Diagram 

```
┌─────────┐     ┌──────────────┐     ┌────────────────────┐
│auth.users│────▶│   profiles   │────▶│   subscriptions    │
└─────────┘     └──────┬───────┘     └────────────────────┘
                       │
       ┌───────────────┼───────────────────────────────────────┐
       │               │                                       │
       ▼               ▼                                       ▼
┌─────────────┐  ┌───────────┐  ┌──────────┐          ┌────────────┐
│  api_keys   │  │ personas  │  │instances │          │   chats    │
└─────────────┘  └─────┬─────┘  └────┬─────┘          └─────┬──────┘
                       │             │                       │
        ┌──────────────┼─────────────┼───────────────────────┤
        │              │             │                       │
        ▼              ▼             ▼                       ▼
┌──────────────┐ ┌──────────┐ ┌──────────────┐      ┌───────────┐
│persona_skills│ │persona_  │ │instance_     │      │ messages  │
│              │ │memories  │ │personas      │      │           │
└──────────────┘ └────┬─────┘ └──────────────┘      └───────────┘
                      │
┌──────────────┐      │       ┌──────────────┐
│persona_      │      ▼       │chat_         │
│boundaries    │ ┌──────────┐ │participants  │
└──────────────┘ │memory_   │ └──────────────┘
                 │edges     │
┌──────────────┐ └──────────┘ ┌──────────────┐
│persona_      │              │memory_       │
│health_snaps  │              │checkpoints   │
└──────────────┘              └──────────────┘

┌─────────┐    ┌──────────────┐    ┌────────────┐
│  teams  │───▶│team_members  │    │   files    │
└────┬────┘    └──────────────┘    └────────────┘
     │
     ▼
┌──────────┐    ┌──────────────┐
│team_tasks│───▶│  team_runs   │
└──────────┘    └──────────────┘

┌─────────────────┐    ┌──────────────┐    ┌───────────────┐
│browser_sessions │───▶│browser_tabs  │    │browser_history│
└─────────────────┘    └──────────────┘    └───────────────┘
                       ┌──────────────────┐
                       │browser_extracts  │───▶┌──────────────────┐
                       └──────────────────┘    │browser_highlights│
                                               └──────────────────┘

┌───────────────┐  ┌──────────────────────┐  ┌──────────────┐
│model_registry │  │model_role_assignments│  │ token_usage  │
└───────────────┘  └──────────────────────┘  └──────────────┘

┌──────────────────┐  ┌─────────────────┐  ┌───────────────┐
│instruction_memory│  │ notifications   │  │ activity_log  │
└──────────────────┘  └─────────────────┘  └───────────────┘

┌──────────────┐  ┌──────────────┐  ┌───────────────────┐
│search_history│  │saved_results │  │rate_limit_counters│
└──────────────┘  └──────────────┘  └───────────────────┘

┌──────────────────┐  ┌───────────────┐  ┌───────────────┐
│credit_transactions│  │instance_types│  │export_history │
└──────────────────┘  └───────────────┘  └───────────────┘

┌──────────────┐  ┌────────────────┐  ┌──────────┐
│  tasks       │  │workspace_items │  │persona_  │
│              │  │                │  │templates │
└──────────────┘  └────────────────┘  └──────────┘
```

### Foreign Key Reference Map 

| Child Table | Column | References | On Delete |
| :---- | :---- | :---- | :---- |
| profiles | id | auth.users(id) | CASCADE |
| subscriptions | user\_id | profiles(id) | CASCADE |
| api\_keys | user\_id | profiles(id) | CASCADE |
| credit\_transactions | user\_id | profiles(id) | CASCADE |
| chats | user\_id | profiles(id) | CASCADE |
| chats | instance\_id | instances(id) | SET NULL |
| chat\_participants | chat\_id | chats(id) | CASCADE |
| chat\_participants | persona\_id | personas(id) | CASCADE |
| messages | chat\_id | chats(id) | CASCADE |
| export\_history | user\_id | profiles(id) | CASCADE |
| export\_history | chat\_id | chats(id) | SET NULL |
| instances | user\_id | profiles(id) | CASCADE |
| instance\_personas | instance\_id | instances(id) | CASCADE |
| instance\_personas | persona\_id | personas(id) | CASCADE |
| instance\_types | user\_id | profiles(id) | CASCADE |
| tasks | instance\_id | instances(id) | CASCADE |
| tasks | user\_id | profiles(id) | CASCADE |
| tasks | assigned\_persona\_id | personas(id) | SET NULL |
| tasks | source\_chat\_id | chats(id) | SET NULL |
| tasks | source\_message\_id | messages(id) | SET NULL |
| workspace\_items | instance\_id | instances(id) | CASCADE |
| workspace\_items | user\_id | profiles(id) | CASCADE |
| workspace\_items | file\_id | files(id) | SET NULL |
| workspace\_items | chat\_id | chats(id) | SET NULL |
| files | user\_id | profiles(id) | CASCADE |
| files | instance\_id | instances(id) | SET NULL |
| files | chat\_id | chats(id) | SET NULL |
| files | message\_id | messages(id) | SET NULL |
| personas | user\_id | profiles(id) | CASCADE |
| personas | created\_from\_template | persona\_templates(id) | SET NULL |
| persona\_skills | persona\_id | personas(id) | CASCADE |
| persona\_boundaries | persona\_id | personas(id) | CASCADE |
| persona\_memories | persona\_id | personas(id) | CASCADE |
| persona\_memories | source\_chat\_id | chats(id) | SET NULL |
| persona\_memories | source\_message\_id | messages(id) | SET NULL |
| persona\_health\_snapshots | persona\_id | personas(id) | CASCADE |
| memory\_edges | from\_node\_id | persona\_memories(id) | CASCADE |
| memory\_edges | to\_node\_id | persona\_memories(id) | CASCADE |
| memory\_checkpoints | chat\_id | chats(id) | CASCADE |
| memory\_checkpoints | message\_range\_start | messages(id) | SET NULL |
| memory\_checkpoints | message\_range\_end | messages(id) | SET NULL |
| instruction\_memory | user\_id | profiles(id) | CASCADE |
| instruction\_memory | source\_chat\_id | chats(id) | SET NULL |
| model\_role\_assignments | user\_id | profiles(id) | CASCADE |
| token\_usage | user\_id | profiles(id) | CASCADE |
| token\_usage | chat\_id | chats(id) | SET NULL |
| token\_usage | message\_id | messages(id) | SET NULL |
| teams | user\_id | profiles(id) | CASCADE |
| team\_members | team\_id | teams(id) | CASCADE |
| team\_members | persona\_id | personas(id) | CASCADE |
| team\_tasks | team\_id | teams(id) | CASCADE |
| team\_tasks | user\_id | profiles(id) | CASCADE |
| team\_tasks | source\_chat\_id | chats(id) | SET NULL |
| team\_runs | task\_id | team\_tasks(id) | CASCADE |
| team\_runs | team\_member\_id | team\_members(id) | CASCADE |
| team\_runs | persona\_id | personas(id) | CASCADE |
| browser\_sessions | user\_id | profiles(id) | CASCADE |
| browser\_sessions | instance\_id | instances(id) | SET NULL |
| browser\_tabs | session\_id | browser\_sessions(id) | CASCADE |
| browser\_history | user\_id | profiles(id) | CASCADE |
| browser\_history | session\_id | browser\_sessions(id) | SET NULL |
| browser\_extracts | user\_id | profiles(id) | CASCADE |
| browser\_extracts | session\_id | browser\_sessions(id) | SET NULL |
| browser\_extracts | tab\_id | browser\_tabs(id) | SET NULL |
| browser\_extracts | instance\_id | instances(id) | SET NULL |
| browser\_highlights | user\_id | profiles(id) | CASCADE |
| browser\_highlights | extract\_id | browser\_extracts(id) | CASCADE |
| search\_history | user\_id | profiles(id) | CASCADE |
| saved\_results | user\_id | profiles(id) | CASCADE |
| notifications | user\_id | profiles(id) | CASCADE |
| activity\_log | user\_id | profiles(id) | CASCADE |

---

## 18.17 Schema Statistics 

| Metric | Count |
| :---- | :---- |
| Total tables | 35 |
| Tables with RLS | 35 |
| RLS policies | 48 |
| Indexes | 62 |
| Triggers | 20 |
| RPC functions | 30 |
| Storage buckets | 4 |
| pg\_cron jobs | 4 |
| Foreign key constraints | 68 |
| CHECK constraints | 32 |
| UNIQUE constraints | 8 |
| pgvector columns | 1 (persona\_memories.embedding) |

### Tables by Domain 

| Domain | Tables | Count |
| :---- | :---- | :---- |
| User & Account | profiles, subscriptions, api\_keys, credit\_transactions | 4 |
| Chat System | chats, chat\_participants, messages, export\_history | 4 |
| Spaces & Instances | instances, instance\_personas, instance\_types, tasks, workspace\_items | 5 |
| Files | files | 1 |
| Personas | personas, persona\_skills, persona\_boundaries, persona\_memories, persona\_health\_snapshots, persona\_templates | 6 |
| CogniGraph | memory\_edges, memory\_checkpoints, instruction\_memory | 3 |
| Teams | teams, team\_members, team\_tasks, team\_runs | 4 |
| Browser | browser\_sessions, browser\_tabs, browser\_history, browser\_extracts, browser\_highlights | 5 |
| Search | search\_history, saved\_results | 2 |
| AI Model Routing | model\_registry, model\_role\_assignments, token\_usage, rate\_limit\_counters | 4 |
| System | notifications, activity\_log | 2 |

---

## 18.18 Migration Order 

Tables must be created in dependency order. The following sequence respects all foreign key constraints:

```
-- Phase 1: Extensions and utilities
1.  pgvector, pg_cron, pg_trgm extensions
2.  handle_updated_at() function
3.  handle_new_user() function

-- Phase 2: Root tables (no foreign dependencies)
4.  profiles (depends on auth.users — managed by Supabase)
5.  persona_templates (standalone)
6.  model_registry (standalone, TEXT primary key)

-- Phase 3: First-level dependents (depend on profiles only)
7.  subscriptions
8.  api_keys
9.  credit_transactions
10. instances
11. personas (also references persona_templates)
12. teams
13. notifications
14. activity_log
15. search_history
16. saved_results
17. model_role_assignments
18. instruction_memory
19. instance_types
20. browser_sessions (also references instances)
21. rate_limit_counters (standalone)

-- Phase 4: Second-level dependents
22. chats (references profiles, instances)
23. instance_personas (references instances, personas)
24. persona_skills (references personas)
25. persona_boundaries (references personas)
26. persona_memories (references personas, chats, messages — messages created later, nullable FK)
27. persona_health_snapshots (references personas)
28. team_members (references teams, personas)
29. browser_tabs (references browser_sessions)
30. browser_history (references profiles, browser_sessions)
31. files (references profiles, instances, chats, messages — nullable FKs)
32. token_usage (references profiles, chats, messages — nullable FKs)

-- Phase 5: Third-level dependents
33. chat_participants (references chats, personas)
34. messages (references chats)
35. export_history (references profiles, chats)
36. tasks (references instances, profiles, personas, chats, messages — nullable FKs)
37. workspace_items (references instances, profiles, files, chats — nullable FKs)
38. team_tasks (references teams, profiles, chats)
39. memory_edges (references persona_memories)
40. memory_checkpoints (references chats, messages)
41. browser_extracts (references profiles, browser_sessions, browser_tabs, instances)
42. browser_highlights (references profiles, browser_extracts)

-- Phase 6: Fourth-level dependents
43. team_runs (references team_tasks, team_members, personas)

-- Phase 7: Seed data and triggers
44. Seed model_registry rows
45. Create all triggers (updated_at, handle_new_user, seed_model_roles, seed_instance_types)
46. Create all RPC functions
47. Create storage buckets and policies
48. Schedule pg_cron jobs
```

---

*End of Part 18\. Proceed to Part 19: API Routes & Edge Functions.*

# PART 19: API ROUTES & EDGE FUNCTIONS 

---

This part catalogs every server-side endpoint in aiConnected: the auto-generated REST API from Supabase, every custom Edge Function deployed to the Deno runtime, and every webhook endpoint that receives external events. For each Edge Function, this document specifies the function name, HTTP method, request/response contracts, authentication requirements, the full implementation logic, error handling, and cross-references to the parts where the function's behavior was originally defined.

aiConnected's server-side architecture splits into three layers:

1. **PostgREST API** — Supabase's auto-generated REST endpoints, secured by RLS. The client calls these directly for standard CRUD operations.  
2. **Edge Functions** — Custom Deno functions for logic that cannot be expressed as a database query: AI model calls, streaming, encryption, file generation, external API proxying.  
3. **Webhooks** — Endpoints that receive inbound events from external services (Stripe, future integrations).

---

## 19.1 REST API (Supabase Auto-Generated) 

Supabase provides a PostgREST-based REST API for every table in the `public` schema. The client uses the Supabase JS SDK (`@supabase/supabase-js`), which wraps these endpoints with a fluent query builder.

### Authentication 

All PostgREST calls include the user's JWT in the `Authorization: Bearer {token}` header. The JWT is obtained from Supabase Auth on login and refreshed automatically. Row Level Security (RLS) policies on every table (Part 18\) ensure users can only access their own data.

### Client Access Pattern 

```ts
import { createClient } from "@/lib/supabase/client";

const supabase = createClient();

// SELECT with filters
const { data, error } = await supabase
  .from("chats")
  .select("*, chat_participants(persona_id, personas(name, avatar))")
  .eq("user_id", userId)
  .is("deleted_at", null)
  .order("updated_at", { ascending: false });

// INSERT
const { data, error } = await supabase
  .from("chats")
  .insert({ user_id: userId, title: "New Chat" })
  .select()
  .single();

// UPDATE
const { error } = await supabase
  .from("chats")
  .update({ title: "Renamed Chat" })
  .eq("id", chatId);

// DELETE (soft)
const { error } = await supabase
  .from("chats")
  .update({ deleted_at: new Date().toISOString() })
  .eq("id", chatId);
```

### Endpoint Map 

Every table in Part 18 gets a REST endpoint at `{SUPABASE_URL}/rest/v1/{table_name}`. The full list, grouped by usage pattern:

**Direct client CRUD (read \+ write via SDK):**

| Table | Typical Client Operations |
| :---- | :---- |
| `profiles` | Read own profile, update preferences/display\_name |
| `chats` | List, create, rename, pin, archive, soft-delete |
| `chat_participants` | Add/remove personas from chats |
| `messages` | List by chat, pin/unpin, soft-delete |
| `instances` | List, create, update, archive, soft-delete |
| `instance_personas` | Add/remove personas to/from instances |
| `tasks` | Create, update status/priority, reorder |
| `workspace_items` | Create, update, reorder |
| `files` | List, update metadata/tags, soft-delete |
| `personas` | List, create, update identity/settings |
| `persona_skills` | Add, update, remove skills |
| `persona_boundaries` | Add, update, remove boundaries |
| `persona_memories` | List, update content, toggle active, delete |
| `teams` | List, create, update strategy/config |
| `team_members` | Add, update role/order, remove |
| `browser_sessions` | Create, update settings |
| `browser_tabs` | Create, update URL/title, reorder, remove |
| `search_history` | List recent, delete entries |
| `saved_results` | Save, unsave results |
| `model_role_assignments` | Update primary/fallback models |
| `instruction_memory` | List, toggle active, edit rule, delete |
| `instance_types` | List, create, update, delete (Premium+) |
| `notifications` | List, mark read, delete |

**Read-only client access:**

| Table | Client Operations |
| :---- | :---- |
| `model_registry` | List available models (filtered by tier) |
| `persona_templates` | List templates for persona creation |
| `activity_log` | List recent activity (home dashboard) |
| `persona_health_snapshots` | List health history (persona detail) |
| `export_history` | List past exports |
| `credit_transactions` | List credit history |
| `subscriptions` | Read current subscription status |
| `token_usage` | List usage records (analytics) |
| `memory_edges` | List edges for a persona's memories |
| `memory_checkpoints` | List checkpoints for a chat |

**Service-role only (Edge Functions write, client reads):**

| Table | Writer |
| :---- | :---- |
| `messages` (persona/system/cipher types) | `cipher-route` |
| `persona_memories` (extraction) | `cipher-route` → `cipher-memory` |
| `memory_edges` | `cipher-memory` |
| `memory_checkpoints` | `cipher-memory` |
| `persona_health_snapshots` | `cipher-health` |
| `token_usage` | `cipher-route` |
| `rate_limit_counters` | `cipher-route` |
| `activity_log` (persona/system entries) | Various Edge Functions |
| `team_runs` | `team-execute` |

### RPC Endpoints 

Server-side PostgreSQL functions (Part 18, Section 18.13) are exposed as RPC endpoints at `{SUPABASE_URL}/rest/v1/rpc/{function_name}`:

```ts
// Client calls an RPC function
const { data } = await supabase.rpc("search_persona_memories", {
  p_persona_id: personaId,
  p_embedding: embedding,
  p_match_threshold: 0.5,
  p_match_count: 20,
});
```

**All RPC endpoints:**

| Function | Called By | Purpose |
| :---- | :---- | :---- |
| `search_chat_titles` | Search screen | Full-text search on chat titles |
| `search_message_content` | Search screen | Full-text search on message content |
| `search_files` | Search screen | Full-text search on file names |
| `search_memories_semantic` | Search screen | Semantic vector search on memories |
| `trim_search_history` | Search screen | Keep history under 50 entries |
| `search_persona_memories` | cipher-route, search | Semantic search within a persona's memories |
| `search_instance_memories` | cipher-route | Cross-persona semantic search within an instance |
| `persona_memory_stability` | cipher-health | Compute memory conflict ratio |
| `cognigraph_stats` | Analytics screen | Aggregate CogniGraph statistics |
| `analytics_conversation_stats` | Analytics Edge Function | Chat/message counts per interval |
| `analytics_model_usage` | Analytics Edge Function | Model call counts and token usage |
| `analytics_memory_health` | Analytics Edge Function | Memory type distribution and conflicts |
| `get_storage_usage` | Settings screen, analytics | File storage consumption |
| `daily_token_usage` | Analytics screen | Per-day token breakdown |
| `billing_period_usage` | Settings billing section | Cumulative usage for billing period |
| `model_health_stats` | Analytics, cipher-route | Model success rate and latency |

---

## 19.2 Edge Functions 

Edge Functions run on Supabase's Deno runtime. They are deployed as individual functions under `supabase/functions/{function-name}/index.ts`. Each function is invoked via HTTP at `{SUPABASE_URL}/functions/v1/{function-name}`.

### Shared Infrastructure 

Every Edge Function starts with the same boilerplate for authentication, CORS, and error handling:

```ts
// supabase/functions/_shared/cors.ts
export const CORS_HEADERS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
  "Access-Control-Allow-Headers": "authorization, content-type, x-client-info, apikey",
};

// supabase/functions/_shared/auth.ts
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

export async function authenticateRequest(req: Request) {
  const authHeader = req.headers.get("Authorization");
  if (!authHeader) throw { status: 401, message: "Missing authorization header" };

  const token = authHeader.replace("Bearer ", "");
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: `Bearer ${token}` } } }
  );

  const { data: { user }, error } = await supabase.auth.getUser(token);
  if (error || !user) throw { status: 401, message: "Invalid or expired token" };

  return { supabase, user };
}

// Service client (for operations that bypass RLS)
export function createServiceClient() {
  return createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );
}
```

```ts
// supabase/functions/_shared/errors.ts
import { CORS_HEADERS } from "./cors.ts";

export function errorResponse(error: any): Response {
  const status = error.status ?? 500;
  const message = error.message ?? "Internal server error";

  return new Response(
    JSON.stringify({ error: message }),
    {
      status,
      headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
    }
  );
}

export function jsonResponse(data: any, status = 200): Response {
  return new Response(
    JSON.stringify(data),
    {
      status,
      headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
    }
  );
}
```

### Function Registry 

| \# | Function Name | Method | Auth | Invocation | Source Parts |
| :---- | :---- | :---- | :---- | :---- | :---- |
| 1 | `cipher-route` | POST | User JWT | Client on message send | 5, 14, 16, 17 |
| 2 | `cipher-memory` | POST | Service role | cipher-route (fire-and-forget) | 14, 15 |
| 3 | `cipher-cleanup` | POST | Service role | pg\_cron (hourly) | 14, 15 |
| 4 | `cipher-health` | POST | Service role | pg\_cron (daily) | 14 |
| 5 | `search` | POST | User JWT | Client on search query | 6 |
| 6 | `analytics` | POST | User JWT | Client on analytics load | 12 |
| 7 | `chat-export` | POST | User JWT | Client on export request | 5 |
| 8 | `browser-proxy` | GET | User JWT | iframe src in browser | 11 |
| 9 | `browser-extract` | POST | User JWT | Client on extract action | 11 |
| 10 | `team-execute` | POST | Service role | Client or pg\_cron | 10 |
| 11 | `validate-api-key` | POST | User JWT | Client on key validation | 13 |
| 12 | `store-api-key` | POST | User JWT | Client on key save | 13 |
| 13 | `stripe-webhook` | POST | Stripe signature | Stripe events | 2 |
| 14 | `create-checkout-session` | POST | User JWT | Client on plan select | 2 |
| 15 | `files-zip` | POST | User JWT | Client on bulk download | 8 |
| 16 | `generate-embedding` | POST | Service role | Internal (other functions) | 14, 15, 16 |

---

## 19.3 Function 1: cipher-route 

The primary message processing pipeline. Receives a user message, routes it through Cipher, streams the AI response back via SSE. This is the most complex Edge Function in the system.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/cipher-route` |
| Auth | User JWT (Bearer token) |
| Response | `text/event-stream` (SSE) |
| Timeout | 120 seconds |
| Detailed spec | Part 14 (Sections 14.2–14.7), Part 16 (Sections 16.3, 16.6), Part 17 (Section 17.4) |

### Request 

```ts
interface CipherRouteRequest {
  chat_id: string;
  content: string;
  directed_persona_id?: string | null;  // Explicit @mention target
  model_override?: string | null;       // One-shot model override from composer
  attachments?: {
    file_id: string;
    name: string;
    mime_type: string;
    summary?: string;
  }[];
}
```

### Response (SSE stream) 

```
data: {"type":"status","status":"routing"}

data: {"type":"status","status":"generating"}

data: {"type":"token","content":"Hello"}

data: {"type":"token","content":", how"}

data: {"type":"token","content":" can I help?"}

data: {"type":"done","message_id":"uuid","model_used":"claude-sonnet-4-5-20250929","routing_reason":"skill_match","routing_note":"Routed to Atlas (Writing skill match)","token_count":42,"latency_ms":1200,"prompt_tokens":1500,"completion_tokens":42,"estimated_cost":0.0051,"fallback_used":false,"fallback_note":""}
```

Error event:

```
data: {"type":"error","message":"Rate limit reached","code":429,"retryable":true,"retry_after_seconds":30}
```

### Pipeline Steps 

Defined in detail in Part 14 (Section 14.2). Summary:

1. Authenticate user via JWT  
2. Check rate limits (Part 16, Section 16.5)  
3. Load context in parallel: profile, chat, participants, recent messages, instance  
4. Insert user message into `messages`  
5. Route to persona via priority cascade (Part 14, Section 14.3)  
6. Validate persona skills/boundaries (Part 14, Section 14.4)  
7. Select model via role assignment \+ fallback chain (Part 14, Section 14.5; Part 16, Section 16.6)  
8. Assemble context window with token budgeting (Part 14, Section 14.6; Part 15, Section 15.8)  
9. Stream response via provider abstraction (Part 16, Sections 16.2–16.3)  
10. On completion: insert persona message, record token usage, broadcast typing-stop event  
11. Fire-and-forget: invoke `cipher-memory` for post-response processing

### Typing Indicator Integration 

Before streaming begins, the function broadcasts a typing event on the chat's Realtime channel (Part 17, Section 17.4):

```ts
// Broadcast typing-start before model call
await serviceClient
  .channel(`chat:${chatId}`)
  .send({
    type: "broadcast",
    event: "typing",
    payload: { persona_id: personaId, persona_name: personaName, status: "typing" },
  });

// After stream completes
await serviceClient
  .channel(`chat:${chatId}`)
  .send({
    type: "broadcast",
    event: "typing",
    payload: { persona_id: personaId, status: "stopped" },
  });
```

---

## 19.4 Function 2: cipher-memory 

Asynchronous post-response processing. Extracts memories, detects instructions, checks auto-rename and suggested move triggers, creates checkpoints, and logs activity.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/cipher-memory` |
| Auth | Service role key (internal invocation only) |
| Response | JSON `{ success: true }` |
| Timeout | 30 seconds |
| Detailed spec | Part 14 (Section 14.8), Part 15 (Sections 15.4, 15.5, 15.7) |

### Request 

```ts
interface CipherMemoryRequest {
  user_id: string;
  chat_id: string;
  user_message_id: string;
  persona_message_id: string;
  persona_id: string;
  persona_name: string;
  user_content: string;
  persona_content: string;
}
```

### Tasks (run in parallel via Promise.allSettled) 

| \# | Task | Description | Source |
| :---- | :---- | :---- | :---- |
| 1 | Memory extraction | Analyze exchange, create persona\_memories, create memory\_edges | Part 14.8, Part 15.4–15.5 |
| 2 | Instruction detection | Detect explicit rules, create instruction\_memory | Part 14.8 |
| 3 | Auto-rename check | If 6+ messages and no auto\_title, generate title | Part 14.8 |
| 4 | Suggested move check | If no instance\_id and 5+ user messages, suggest an instance | Part 14.8 |
| 5 | Checkpoint check | If checkpoint trigger met, create memory\_checkpoint | Part 15.7 |
| 6 | Activity log | Insert persona response activity entry | Part 14.8 |
| 7 | Update chat timestamp | Touch chats.updated\_at | Part 14.8 |

### Background Model Usage 

All AI calls in this function use the platform key and the background model (`claude-haiku-4-5-20251001`). Token usage is recorded with `provider_used = 'platform_background'` (Part 16, Section 16.8).

---

## 19.5 Function 3: cipher-cleanup 

Scheduled background maintenance for memory decay, stale flagging, expired skill cleanup, soft-delete purging, and unnamed chat titling.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/cipher-cleanup` |
| Auth | Service role key (pg\_cron invocation) |
| Response | JSON `{ users_processed: number, tasks: {...} }` |
| Schedule | Hourly (checks each user's `cleanup_frequency` preference) |
| Timeout | 300 seconds (5 minutes) |
| Detailed spec | Part 14 (Section 14.9), Part 15 (Section 15.4) |

### Cleanup Tasks per User 

| \# | Task | Description | Source |
| :---- | :---- | :---- | :---- |
| 1 | Flag stale memories | Tag active memories untouched \>90 days as "stale" | Part 14.9 |
| 2 | Decay open memories | Reduce confidence of unreinforced open-layer memories | Part 15.4 |
| 3 | Expire temporary skills | Hard-delete persona\_skills where `temporary=true AND expires_at < now()` | Part 14.9 |
| 4 | Purge soft-deleted chats | Hard-delete chats with `deleted_at` \>30 days ago | Part 14.9 |
| 5 | Purge deactivated memories | Hard-delete inactive memories older than 90 days | Part 15.4 |
| 6 | Title unnamed chats | Generate auto-titles for chats still named "New Chat" older than 7 days | Part 14.9 |

---

## 19.6 Function 4: cipher-health 

Daily health monitoring that computes persona drift, mood, and memory stability.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/cipher-health` |
| Auth | Service role key (pg\_cron invocation) |
| Response | JSON `{ personas_processed: number }` |
| Schedule | Daily at 03:00 UTC |
| Timeout | 300 seconds |
| Detailed spec | Part 14 (Section 14.10) |

### Processing per Persona 

1. Compute memory stability via `persona_memory_stability` RPC  
2. Count messages and chats in last 7 days  
3. Count active conflict-tagged memories  
4. Compute drift level (low/medium/high) based on scoring matrix  
5. Compute mood based on activity \+ drift  
6. Insert `persona_health_snapshots` row  
7. Update `personas.mood`

---

## 19.7 Function 5: search 

Unified search across all content types: chats, messages, files, personas, instances, and memories.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/search` |
| Auth | User JWT |
| Response | JSON |
| Timeout | 15 seconds |
| Detailed spec | Part 6 (Sections 6.4–6.5) |

### Request 

```ts
interface SearchRequest {
  query: string;
  filters?: {
    types?: ("chats" | "messages" | "files" | "personas" | "instances" | "memories")[];
    instance_id?: string;
    persona_id?: string;
    date_from?: string;
    date_to?: string;
  };
  limit?: number;       // Default: 20
  offset?: number;      // Default: 0
}
```

### Response 

```ts
interface SearchResponse {
  results: SearchResult[];
  total: number;
  query: string;
  execution_time_ms: number;
}

interface SearchResult {
  type: "chat" | "message" | "file" | "persona" | "instance" | "memory";
  id: string;
  title: string;
  snippet: string;
  relevance_score: number;
  metadata: Record<string, any>;
  created_at: string;
}
```

### Implementation 

Runs up to 6 parallel queries (one per content type), merges results, ranks by relevance score, and returns the top N. Uses full-text search RPC functions for text content and semantic search for memories (Part 6, Section 6.5).

```ts
serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: CORS_HEADERS });

  try {
    const { supabase, user } = await authenticateRequest(req);
    const body: SearchRequest = await req.json();
    const startTime = Date.now();

    const types = body.filters?.types ?? ["chats", "messages", "files", "personas", "instances", "memories"];
    const results: SearchResult[] = [];

    const searches = await Promise.allSettled([
      types.includes("chats")
        ? supabase.rpc("search_chat_titles", { p_user_id: user.id, p_query: body.query, p_limit: body.limit ?? 20 })
        : null,
      types.includes("messages")
        ? supabase.rpc("search_message_content", { p_user_id: user.id, p_query: body.query, p_limit: body.limit ?? 20 })
        : null,
      types.includes("files")
        ? supabase.rpc("search_files", { p_user_id: user.id, p_query: body.query, p_limit: body.limit ?? 20 })
        : null,
      types.includes("personas")
        ? supabase.from("personas").select("id, name, role").eq("user_id", user.id).ilike("name", `%${body.query}%`).limit(10)
        : null,
      types.includes("instances")
        ? supabase.from("instances").select("id, name, description").eq("user_id", user.id).ilike("name", `%${body.query}%`).limit(10)
        : null,
      types.includes("memories")
        ? searchMemoriesSemantic(supabase, user.id, body.query)
        : null,
    ]);

    // Merge and format results from each search
    // (detailed merging logic defined in Part 6)

    // Record search history
    await supabase.from("search_history").insert({
      user_id: user.id,
      query: body.query,
      result_count: results.length,
      filters: body.filters ?? {},
    });

    // Trim history if needed
    await supabase.rpc("trim_search_history", { p_user_id: user.id });

    return jsonResponse({
      results: results.slice(0, body.limit ?? 20),
      total: results.length,
      query: body.query,
      execution_time_ms: Date.now() - startTime,
    });
  } catch (error) {
    return errorResponse(error);
  }
});
```

---

## 19.8 Function 6: analytics 

Computes aggregated analytics data for the Insights screen.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/analytics` |
| Auth | User JWT |
| Response | JSON |
| Timeout | 30 seconds |
| Detailed spec | Part 12 (Sections 12.2–12.9) |

### Request 

```ts
interface AnalyticsRequest {
  type: "overview" | "personas" | "models" | "memory";
  interval: "7d" | "30d" | "90d" | "1y";
}
```

### Response 

Returns one of four data shapes depending on `type`:

| Type | Response Interface | Source |
| :---- | :---- | :---- |
| `overview` | `OverviewData` — conversation stats, top model, most active persona, storage, daily activity | Part 12.2 |
| `personas` | `PersonaPerformanceData[]` — per-persona message count, chat count, memory stats, skills, last active | Part 12.5 |
| `models` | `ModelUsageData` — per-model call count, token usage, estimated cost, BYOK status | Part 12.7 |
| `memory` | `MemoryHealthData` — total active, by type, conflicts, avg confidence, memories this week | Part 12.9 |

### Implementation 

Dispatches to the appropriate compute function based on `type`, executing multiple RPC calls in parallel via `Promise.all`. Each compute function is detailed in Part 12\.

---

## 19.9 Function 7: chat-export 

Generates an export file for a chat in the requested format.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/chat-export` |
| Auth | User JWT |
| Response | JSON `{ download_url: string, file_size: number }` |
| Timeout | 60 seconds |
| Detailed spec | Part 5 (Section 5.7) |

### Request 

```ts
interface ChatExportRequest {
  chat_id: string;
  format: "markdown" | "json" | "pdf" | "html" | "encrypted_zip";
  scope: "full" | "filtered";
  filters?: {
    sent?: boolean;
    received?: boolean;
    pinned?: boolean;
    links?: boolean;
    media?: boolean;
    search_query?: string;
  };
  passphrase?: string;   // Required for encrypted_zip
}
```

### Response 

```ts
interface ChatExportResponse {
  download_url: string;    // Signed URL to the exports storage bucket
  file_size: number;       // Bytes
  format: string;
  export_id: string;       // ID of the export_history row
}
```

### Implementation 

```ts
serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: CORS_HEADERS });

  try {
    const { supabase, user } = await authenticateRequest(req);
    const body: ChatExportRequest = await req.json();
    const serviceClient = createServiceClient();

    // Verify chat ownership
    const { data: chat } = await supabase
      .from("chats")
      .select("id, title, user_id")
      .eq("id", body.chat_id)
      .eq("user_id", user.id)
      .single();

    if (!chat) throw { status: 404, message: "Chat not found" };

    // Check tier permissions
    const { data: profile } = await supabase.from("profiles").select("tier").eq("id", user.id).single();
    const tier = profile?.tier ?? "free";

    const allowedFormats = getAllowedExportFormats(tier);
    if (!allowedFormats.includes(body.format)) {
      throw { status: 403, message: `${body.format} export requires a higher plan` };
    }

    // Load messages
    let query = supabase
      .from("messages")
      .select("*, sender:personas(name, avatar)")
      .eq("chat_id", body.chat_id)
      .is("deleted_at", null)
      .order("created_at", { ascending: true });

    if (body.scope === "filtered" && body.filters) {
      if (body.filters.pinned) query = query.eq("pinned", true);
      if (body.filters.links) query = query.eq("has_link", true);
      if (body.filters.media) query = query.eq("has_media", true);
      if (body.filters.sent) query = query.eq("sender_type", "user");
      if (body.filters.received) query = query.eq("sender_type", "persona");
    }

    const { data: messages } = await query;

    // Load participants
    const { data: participants } = await supabase
      .from("chat_participants")
      .select("persona_id, personas(name, role)")
      .eq("chat_id", body.chat_id)
      .is("removed_at", null);

    // Generate file content
    let fileContent: Uint8Array;
    let fileName: string;
    let mimeType: string;

    switch (body.format) {
      case "markdown":
        fileContent = new TextEncoder().encode(generateMarkdown(chat, messages, participants));
        fileName = `${sanitizeFilename(chat.title)}.md`;
        mimeType = "text/markdown";
        break;

      case "json":
        fileContent = new TextEncoder().encode(JSON.stringify({
          chat: { id: chat.id, title: chat.title },
          participants: participants?.map(p => p.personas) ?? [],
          messages: messages ?? [],
          exported_at: new Date().toISOString(),
        }, null, 2));
        fileName = `${sanitizeFilename(chat.title)}.json`;
        mimeType = "application/json";
        break;

      case "html":
        fileContent = new TextEncoder().encode(generateHTML(chat, messages, participants));
        fileName = `${sanitizeFilename(chat.title)}.html`;
        mimeType = "text/html";
        break;

      case "pdf":
        fileContent = await generatePDF(chat, messages, participants);
        fileName = `${sanitizeFilename(chat.title)}.pdf`;
        mimeType = "application/pdf";
        break;

      case "encrypted_zip":
        if (!body.passphrase) throw { status: 400, message: "Passphrase required for encrypted export" };
        fileContent = await generateEncryptedZip(chat, messages, participants, body.passphrase);
        fileName = `${sanitizeFilename(chat.title)}.zip`;
        mimeType = "application/zip";
        break;

      default:
        throw { status: 400, message: `Unsupported format: ${body.format}` };
    }

    // Upload to exports bucket
    const storagePath = `${user.id}/${crypto.randomUUID()}/${fileName}`;
    const { error: uploadError } = await serviceClient.storage
      .from("exports")
      .upload(storagePath, fileContent, { contentType: mimeType });

    if (uploadError) throw { status: 500, message: "Failed to upload export file" };

    // Generate signed download URL (1 hour expiry)
    const { data: urlData } = await serviceClient.storage
      .from("exports")
      .createSignedUrl(storagePath, 3600);

    // Record export history
    await serviceClient.from("export_history").insert({
      user_id: user.id,
      chat_id: body.chat_id,
      format: body.format,
      status: "completed",
      file_path: storagePath,
      file_size_bytes: fileContent.length,
    });

    return jsonResponse({
      download_url: urlData?.signedUrl,
      file_size: fileContent.length,
      format: body.format,
      export_id: crypto.randomUUID(),
    });
  } catch (error) {
    return errorResponse(error);
  }
});

function getAllowedExportFormats(tier: string): string[] {
  switch (tier) {
    case "free": return ["markdown"];
    case "plus": return ["markdown", "json"];
    case "premium": return ["markdown", "json", "pdf", "html"];
    case "pro": return ["markdown", "json", "pdf", "html", "encrypted_zip"];
    default: return ["markdown"];
  }
}

function sanitizeFilename(title: string): string {
  return title.replace(/[^a-zA-Z0-9\s\-_]/g, "").replace(/\s+/g, "-").toLowerCase().slice(0, 50);
}
```

### Format Generators 

**Markdown:**

```ts
function generateMarkdown(chat: any, messages: any[], participants: any[]): string {
  const lines = [`# ${chat.title}\n`];
  lines.push(`**Participants:** ${(participants ?? []).map(p => p.personas?.name).join(", ")}`);
  lines.push(`**Exported:** ${new Date().toISOString()}\n`);
  lines.push("---\n");

  for (const msg of messages ?? []) {
    const time = new Date(msg.created_at).toLocaleString();
    const sender = msg.sender_type === "user" ? "You" : msg.sender_name;
    lines.push(`**${sender}** (${time}):\n`);
    lines.push(`${msg.content}\n`);
  }

  return lines.join("\n");
}
```

**HTML:**

```ts
function generateHTML(chat: any, messages: any[], participants: any[]): string {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>${chat.title} — aiConnected Export</title>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 720px; margin: 0 auto; padding: 40px 20px; color: #1a1a2e; }
    h1 { font-size: 24px; font-weight: 600; margin-bottom: 8px; }
    .meta { font-size: 13px; color: #6b7280; margin-bottom: 24px; }
    .message { padding: 16px 0; border-bottom: 1px solid #e5e7eb; }
    .sender { font-weight: 500; font-size: 14px; }
    .time { font-size: 11px; color: #9ca3af; margin-left: 8px; }
    .content { font-size: 14px; line-height: 1.6; margin-top: 6px; white-space: pre-wrap; }
    .persona { color: #2e95f3; }
    .user { color: #1a1a2e; }
  </style>
</head>
<body>
  <h1>${chat.title}</h1>
  <div class="meta">Exported from aiConnected on ${new Date().toLocaleDateString()}</div>
  ${(messages ?? []).map(msg => `
    <div class="message">
      <span class="sender ${msg.sender_type === "user" ? "user" : "persona"}">${msg.sender_type === "user" ? "You" : msg.sender_name}</span>
      <span class="time">${new Date(msg.created_at).toLocaleString()}</span>
      <div class="content">${escapeHtml(msg.content)}</div>
    </div>
  `).join("")}
</body>
</html>`;
}

function escapeHtml(text: string): string {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}
```

---

## 19.10 Function 8: browser-proxy 

Server-side proxy that fetches web pages for the browser viewport, rewriting URLs and stripping restrictive headers.

| Property | Value |
| :---- | :---- |
| Path | `GET /functions/v1/browser-proxy?url={encoded_url}` |
| Auth | User JWT (via query param or header) |
| Response | `text/html` (proxied page content) |
| Timeout | 30 seconds |
| Detailed spec | Part 11 (Section 11.1) |

### Implementation 

```ts
serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: CORS_HEADERS });

  try {
    const { user } = await authenticateRequest(req);

    const url = new URL(req.url);
    const targetUrl = url.searchParams.get("url");
    if (!targetUrl) throw { status: 400, message: "Missing url parameter" };

    // Validate URL
    let parsed: URL;
    try {
      parsed = new URL(targetUrl);
    } catch {
      throw { status: 400, message: "Invalid URL" };
    }

    // Block internal/private URLs
    const blockedHosts = ["localhost", "127.0.0.1", "0.0.0.0", "metadata.google.internal"];
    if (blockedHosts.some(h => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`))) {
      throw { status: 403, message: "URL not allowed" };
    }

    // Fetch the target page
    const response = await fetch(targetUrl, {
      headers: {
        "User-Agent": "Mozilla/5.0 (compatible; aiConnected/1.0)",
        "Accept": "text/html,application/xhtml+xml,*/*",
      },
      redirect: "follow",
    });

    if (!response.ok) {
      throw { status: response.status, message: `Target returned ${response.status}` };
    }

    const contentType = response.headers.get("content-type") ?? "";

    // For HTML: rewrite relative URLs and inject overlay script
    if (contentType.includes("text/html")) {
      let html = await response.text();
      html = rewriteRelativeUrls(html, targetUrl);
      html = injectOverlayScript(html);
      return new Response(html, {
        headers: {
          ...CORS_HEADERS,
          "Content-Type": "text/html; charset=utf-8",
        },
      });
    }

    // For other content types: pass through
    return new Response(response.body, {
      headers: {
        ...CORS_HEADERS,
        "Content-Type": contentType,
      },
    });
  } catch (error) {
    return errorResponse(error);
  }
});

function rewriteRelativeUrls(html: string, baseUrl: string): string {
  const base = new URL(baseUrl);

  // Rewrite href and src attributes with relative paths
  return html
    .replace(/(href|src|action)="(?!https?:\/\/|\/\/|data:|javascript:|#|mailto:)([^"]+)"/g, (match, attr, path) => {
      try {
        const absolute = new URL(path, base).toString();
        return `${attr}="${absolute}"`;
      } catch {
        return match;
      }
    })
    .replace(/(href|src|action)='(?!https?:\/\/|\/\/|data:|javascript:|#|mailto:)([^']+)'/g, (match, attr, path) => {
      try {
        const absolute = new URL(path, base).toString();
        return `${attr}='${absolute}'`;
      } catch {
        return match;
      }
    });
}

function injectOverlayScript(html: string): string {
  const script = `<script>
    // aiConnected browser overlay bridge
    window.aiConnected = {
      extractText: () => document.body.innerText,
      extractHTML: () => document.documentElement.outerHTML,
      getTitle: () => document.title,
      getSelection: () => window.getSelection()?.toString() ?? "",
    };
    // Notify parent frame of page load
    window.parent.postMessage({ type: "page-loaded", title: document.title, url: window.location.href }, "*");
  </script>`;

  if (html.includes("</body>")) {
    return html.replace("</body>", `${script}</body>`);
  }
  return html + script;
}
```

### Browser History Recording 

The client records history entries on navigation. The proxy does not write to `browser_history` — this is handled by the client's `onNavigate` handler (Part 11).

---

## 19.11 Function 9: browser-extract 

Extracts and stores page content from the browser viewport.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/browser-extract` |
| Auth | User JWT |
| Response | JSON `{ extract_id: string }` |
| Timeout | 30 seconds |
| Detailed spec | Part 11 (Section 11.5) |

### Request 

```ts
interface BrowserExtractRequest {
  session_id: string;
  tab_id: string;
  url: string;
  title: string;
  content: string;              // Raw text or HTML from the page
  content_type: "text" | "markdown" | "html" | "screenshot";
  instance_id?: string | null;  // Optional: attach to an instance
  tags?: string[];
}
```

### Implementation 

```ts
serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: CORS_HEADERS });

  try {
    const { supabase, user } = await authenticateRequest(req);
    const body: BrowserExtractRequest = await req.json();

    // If content is large, store in browser-extracts bucket
    let storagePath: string | null = null;
    if (body.content.length > 50000) {
      const fileName = `${crypto.randomUUID()}.${body.content_type === "screenshot" ? "png" : "txt"}`;
      storagePath = `${user.id}/${fileName}`;
      const serviceClient = createServiceClient();
      await serviceClient.storage.from("browser-extracts").upload(storagePath, body.content);
    }

    const { data: extract, error } = await supabase.from("browser_extracts").insert({
      user_id: user.id,
      session_id: body.session_id,
      tab_id: body.tab_id,
      url: body.url,
      title: body.title,
      content: body.content.length > 50000 ? body.content.slice(0, 500) + "..." : body.content,
      content_type: body.content_type,
      storage_path: storagePath,
      instance_id: body.instance_id,
      tags: body.tags ?? [],
    }).select().single();

    if (error) throw { status: 500, message: error.message };

    return jsonResponse({ extract_id: extract.id });
  } catch (error) {
    return errorResponse(error);
  }
});
```

---

## 19.12 Function 10: team-execute 

Orchestrates agentic team execution. Processes team tasks through the DAG-based execution plan.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/team-execute` |
| Auth | Service role key (invoked by client via service call or pg\_cron) |
| Response | JSON `{ run_id: string, status: string }` |
| Timeout | 600 seconds (10 minutes) |
| Detailed spec | Part 10 (Section 10.8) |

### Request 

```ts
interface TeamExecuteRequest {
  team_id: string;
  task_id?: string;           // If provided, execute a specific task; otherwise create new
  trigger_type: "manual" | "scheduled" | "event";
  input_data?: Record<string, any>;
}
```

### Execution Flow 

1. Load team with members \+ execution config  
2. Create `team_tasks` row (status: "running")  
3. Create `team_runs` rows from execution plan (all "pending")  
4. DAG traversal loop: identify ready tasks (all dependencies completed), execute each  
5. For each run step: build context (team goal \+ task description \+ prior results), call AI model via Cipher routing, save result, update status  
6. Loop until all steps completed, all blocked, or timeout  
7. Update task status to "completed" or "failed"

The full implementation is specified in Part 10, Section 10.8.

---

## 19.13 Function 11: validate-api-key 

Validates a user-provided API key by making a lightweight test request to the provider.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/validate-api-key` |
| Auth | User JWT |
| Response | JSON `{ valid: boolean, error?: string }` |
| Timeout | 15 seconds |
| Detailed spec | Part 13 (Section 13.5) |

### Request 

```ts
interface ValidateKeyRequest {
  provider: "openrouter" | "anthropic" | "openai" | "google";
  key: string;
}
```

### Implementation 

Each provider is validated with a minimal API call:

| Provider | Validation Endpoint | Method |
| :---- | :---- | :---- |
| OpenRouter | `GET /api/v1/auth/key` | Auth header check |
| Anthropic | `POST /v1/messages` with 1-token request | 200 or 429 \= valid |
| OpenAI | `GET /v1/models` | Auth header check |
| Google | `GET /v1/models?key={key}` | API key param check |

Returns `{ valid: true }` on success, `{ valid: false, error: "Invalid API key" }` on failure. Full implementation in Part 13 (Section 13.5).

---

## 19.14 Function 12: store-api-key 

Encrypts and stores a validated API key.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/store-api-key` |
| Auth | User JWT |
| Response | JSON `{ key_id: string }` |
| Timeout | 10 seconds |
| Detailed spec | Part 13 (Section 13.5) |

### Request 

```ts
interface StoreKeyRequest {
  provider: "openrouter" | "anthropic" | "openai" | "google";
  key: string;
}
```

### Implementation 

```ts
serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: CORS_HEADERS });

  try {
    const { user } = await authenticateRequest(req);
    const body: StoreKeyRequest = await req.json();
    const serviceClient = createServiceClient();

    // Encrypt the key
    const encryptionKey = Deno.env.get("API_KEY_ENCRYPTION_SECRET")!;
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const keyMaterial = new TextEncoder().encode(encryptionKey).slice(0, 32);
    const cryptoKey = await crypto.subtle.importKey("raw", keyMaterial, "AES-GCM", false, ["encrypt"]);
    const encrypted = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv },
      cryptoKey,
      new TextEncoder().encode(body.key)
    );

    const ivBase64 = btoa(String.fromCharCode(...iv));
    const encryptedBase64 = btoa(String.fromCharCode(...new Uint8Array(encrypted)));
    const encryptedKey = `${ivBase64}:${encryptedBase64}`;

    // Extract hint (last 4 chars)
    const keyHint = `...${body.key.slice(-4)}`;

    // Check for existing key for this provider
    const { data: existing } = await serviceClient
      .from("api_keys")
      .select("id")
      .eq("user_id", user.id)
      .eq("provider", body.provider)
      .eq("status", "active")
      .limit(1);

    if (existing && existing.length > 0) {
      // Revoke old key
      await serviceClient
        .from("api_keys")
        .update({ status: "revoked" })
        .eq("id", existing[0].id);
    }

    // Insert new key
    const { data: newKey, error } = await serviceClient
      .from("api_keys")
      .insert({
        user_id: user.id,
        provider: body.provider,
        encrypted_key: encryptedKey,
        key_hint: keyHint,
        status: "active",
        last_validated_at: new Date().toISOString(),
      })
      .select("id")
      .single();

    if (error) throw { status: 500, message: error.message };

    return jsonResponse({ key_id: newKey.id });
  } catch (error) {
    return errorResponse(error);
  }
});
```

---

## 19.15 Function 13: stripe-webhook 

Receives and processes Stripe webhook events for subscription management.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/stripe-webhook` |
| Auth | Stripe webhook signature verification |
| Response | JSON `{ received: true }` |
| Timeout | 15 seconds |
| Detailed spec | Part 2 (Section 2.3) |

### Event Handling 

```ts
import Stripe from "https://esm.sh/stripe@14";

const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { apiVersion: "2023-10-16" });

serve(async (req) => {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature");

  if (!signature) return new Response("Missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      Deno.env.get("STRIPE_WEBHOOK_SECRET")!
    );
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  const serviceClient = createServiceClient();

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      const userId = session.metadata?.user_id;
      const tier = session.metadata?.tier;
      if (!userId || !tier) break;

      await serviceClient.from("subscriptions").upsert({
        user_id: userId,
        stripe_customer_id: session.customer as string,
        stripe_subscription_id: session.subscription as string,
        tier,
        status: "active",
        current_period_start: new Date().toISOString(),
      });

      await serviceClient.from("profiles").update({ tier }).eq("id", userId);
      break;
    }

    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      const { data: sub } = await serviceClient
        .from("subscriptions")
        .select("user_id")
        .eq("stripe_subscription_id", subscription.id)
        .single();

      if (sub) {
        const newTier = mapPriceToTier(subscription.items.data[0]?.price.id);
        await serviceClient.from("subscriptions").update({
          tier: newTier,
          status: subscription.status === "active" ? "active" : subscription.status,
          current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
          current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
          cancel_at_period_end: subscription.cancel_at_period_end,
        }).eq("stripe_subscription_id", subscription.id);

        await serviceClient.from("profiles").update({ tier: newTier }).eq("id", sub.user_id);
      }
      break;
    }

    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await serviceClient.from("subscriptions").update({
        tier: "free",
        status: "canceled",
      }).eq("stripe_subscription_id", subscription.id);

      const { data: sub } = await serviceClient
        .from("subscriptions")
        .select("user_id")
        .eq("stripe_subscription_id", subscription.id)
        .single();

      if (sub) {
        await serviceClient.from("profiles").update({ tier: "free" }).eq("id", sub.user_id);
      }
      break;
    }

    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      if (invoice.subscription) {
        await serviceClient.from("subscriptions").update({
          status: "past_due",
        }).eq("stripe_subscription_id", invoice.subscription as string);

        // Create notification for user
        const { data: sub } = await serviceClient
          .from("subscriptions")
          .select("user_id")
          .eq("stripe_subscription_id", invoice.subscription as string)
          .single();

        if (sub) {
          await serviceClient.from("notifications").insert({
            user_id: sub.user_id,
            type: "system_alert",
            title: "Payment failed",
            body: "Your subscription payment could not be processed. Please update your payment method.",
            action_url: "/settings?tab=general",
          });
        }
      }
      break;
    }

    case "invoice.paid": {
      const invoice = event.data.object as Stripe.Invoice;
      if (invoice.subscription) {
        await serviceClient.from("subscriptions").update({
          status: "active",
        }).eq("stripe_subscription_id", invoice.subscription as string)
          .eq("status", "past_due");
      }
      break;
    }
  }

  return jsonResponse({ received: true });
});

function mapPriceToTier(priceId: string): string {
  const priceMap: Record<string, string> = {
    [Deno.env.get("STRIPE_PRICE_PLUS")!]: "plus",
    [Deno.env.get("STRIPE_PRICE_PREMIUM")!]: "premium",
    [Deno.env.get("STRIPE_PRICE_PRO")!]: "pro",
  };
  return priceMap[priceId] ?? "free";
}
```

---

## 19.16 Function 14: create-checkout-session 

Creates a Stripe Checkout session for subscription upgrades or credit purchases.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/create-checkout-session` |
| Auth | User JWT |
| Response | JSON `{ checkout_url: string }` |
| Timeout | 15 seconds |
| Detailed spec | Part 2 (Section 2.3) |

### Request 

```ts
interface CheckoutRequest {
  type: "subscription" | "credits";
  tier?: "plus" | "premium" | "pro";    // For subscription
  credits_pack?: 100 | 300 | 1000;      // For credits
}
```

### Implementation 

```ts
serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: CORS_HEADERS });

  try {
    const { user } = await authenticateRequest(req);
    const body: CheckoutRequest = await req.json();

    let priceId: string;
    let mode: "subscription" | "payment";
    let metadata: Record<string, string> = { user_id: user.id };

    if (body.type === "subscription" && body.tier) {
      const priceMap: Record<string, string> = {
        plus: Deno.env.get("STRIPE_PRICE_PLUS")!,
        premium: Deno.env.get("STRIPE_PRICE_PREMIUM")!,
        pro: Deno.env.get("STRIPE_PRICE_PRO")!,
      };
      priceId = priceMap[body.tier];
      if (!priceId) throw { status: 400, message: "Invalid tier" };
      mode = "subscription";
      metadata.tier = body.tier;
    } else if (body.type === "credits" && body.credits_pack) {
      const creditPriceMap: Record<number, string> = {
        100: Deno.env.get("STRIPE_PRICE_CREDITS_100")!,
        300: Deno.env.get("STRIPE_PRICE_CREDITS_300")!,
        1000: Deno.env.get("STRIPE_PRICE_CREDITS_1000")!,
      };
      priceId = creditPriceMap[body.credits_pack];
      if (!priceId) throw { status: 400, message: "Invalid credits pack" };
      mode = "payment";
      metadata.credits = String(body.credits_pack);
    } else {
      throw { status: 400, message: "Invalid checkout request" };
    }

    const session = await stripe.checkout.sessions.create({
      mode,
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${Deno.env.get("SITE_URL")}/settings?checkout=success`,
      cancel_url: `${Deno.env.get("SITE_URL")}/settings?checkout=canceled`,
      metadata,
      client_reference_id: user.id,
    });

    return jsonResponse({ checkout_url: session.url });
  } catch (error) {
    return errorResponse(error);
  }
});
```

---

## 19.17 Function 15: files-zip 

Creates a ZIP archive of multiple files for bulk download.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/files-zip` |
| Auth | User JWT |
| Response | JSON `{ download_url: string }` |
| Timeout | 60 seconds |
| Detailed spec | Part 8 (Section 8.6) |

### Request 

```ts
interface FilesZipRequest {
  file_ids: string[];
}
```

### Implementation 

```ts
import { ZipWriter, BlobWriter, Uint8ArrayReader } from "https://esm.sh/@zip.js/zip.js@2";

serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: CORS_HEADERS });

  try {
    const { supabase, user } = await authenticateRequest(req);
    const body: FilesZipRequest = await req.json();

    if (!body.file_ids?.length) throw { status: 400, message: "No files specified" };
    if (body.file_ids.length > 50) throw { status: 400, message: "Maximum 50 files per ZIP" };

    // Load file metadata (RLS ensures user owns them)
    const { data: files } = await supabase
      .from("files")
      .select("id, name, storage_path, size_bytes")
      .in("id", body.file_ids)
      .is("deleted_at", null);

    if (!files?.length) throw { status: 404, message: "No files found" };

    // Create ZIP
    const blobWriter = new BlobWriter("application/zip");
    const zipWriter = new ZipWriter(blobWriter);

    for (const file of files) {
      const { data: fileData } = await supabase.storage
        .from("user-files")
        .download(file.storage_path);

      if (fileData) {
        const bytes = new Uint8Array(await fileData.arrayBuffer());
        await zipWriter.add(file.name, new Uint8ArrayReader(bytes));
      }
    }

    await zipWriter.close();
    const zipBlob = await blobWriter.getData();
    const zipBytes = new Uint8Array(await zipBlob.arrayBuffer());

    // Upload to exports bucket
    const serviceClient = createServiceClient();
    const storagePath = `${user.id}/${crypto.randomUUID()}/files-export.zip`;
    await serviceClient.storage.from("exports").upload(storagePath, zipBytes, {
      contentType: "application/zip",
    });

    const { data: urlData } = await serviceClient.storage
      .from("exports")
      .createSignedUrl(storagePath, 3600);

    return jsonResponse({ download_url: urlData?.signedUrl });
  } catch (error) {
    return errorResponse(error);
  }
});
```

---

## 19.18 Function 16: generate-embedding 

Internal utility function that generates vector embeddings for semantic search.

| Property | Value |
| :---- | :---- |
| Path | `POST /functions/v1/generate-embedding` |
| Auth | Service role key (internal only) |
| Response | JSON `{ embedding: number[] }` |
| Timeout | 10 seconds |
| Detailed spec | Part 14 (Section 14.6), Part 15 (Section 15.8) |

### Request 

```ts
interface EmbeddingRequest {
  text: string;
  model?: string;   // Default: "text-embedding-3-small"
}
```

### Implementation 

```ts
import OpenAI from "https://esm.sh/openai@4";

serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: CORS_HEADERS });

  try {
    // Service role auth only
    const authHeader = req.headers.get("Authorization");
    const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
    if (!authHeader?.includes(serviceKey!)) {
      throw { status: 401, message: "Service role required" };
    }

    const body: EmbeddingRequest = await req.json();

    const client = new OpenAI({
      apiKey: Deno.env.get("OPENROUTER_API_KEY")!,
      baseURL: "https://openrouter.ai/api/v1",
    });

    const response = await client.embeddings.create({
      model: body.model ?? "text-embedding-3-small",
      input: body.text.slice(0, 8000),  // Truncate to model input limit
    });

    return jsonResponse({ embedding: response.data[0].embedding });
  } catch (error) {
    return errorResponse(error);
  }
});
```

---

## 19.19 Environment Variables 

All Edge Functions require the following environment variables, set via `supabase secrets set`:

```
# Supabase (auto-provided by platform)
SUPABASE_URL=https://[project-ref].supabase.co
SUPABASE_ANON_KEY=[anon-key]
SUPABASE_SERVICE_ROLE_KEY=[service-role-key]

# AI Model Gateway
OPENROUTER_API_KEY=sk-or-...

# Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_PLUS=price_...
STRIPE_PRICE_PREMIUM=price_...
STRIPE_PRICE_PRO=price_...
STRIPE_PRICE_CREDITS_100=price_...
STRIPE_PRICE_CREDITS_300=price_...
STRIPE_PRICE_CREDITS_1000=price_...

# API Key Encryption
API_KEY_ENCRYPTION_SECRET=[32-byte-hex-string]

# Application
SITE_URL=https://aiconnected.app
```

---

## 19.20 Deployment & Directory Structure 

Edge Functions are deployed from the `supabase/functions/` directory:

```
supabase/
  functions/
    _shared/
      cors.ts
      auth.ts
      errors.ts
      crypto.ts              # API key encryption/decryption utilities
      models.ts              # Model registry helpers
      tokens.ts              # Token estimation utilities
    cipher-route/
      index.ts               # 14.2–14.7 pipeline
    cipher-memory/
      index.ts               # 14.8 post-response tasks
    cipher-cleanup/
      index.ts               # 14.9 background maintenance
    cipher-health/
      index.ts               # 14.10 daily health monitoring
    search/
      index.ts               # 6.5 unified search
    analytics/
      index.ts               # 12.2–12.9 compute functions
    chat-export/
      index.ts               # 5.7 export generation
    browser-proxy/
      index.ts               # 11.1 server-side page proxy
    browser-extract/
      index.ts               # 11.5 content extraction
    team-execute/
      index.ts               # 10.8 DAG execution engine
    validate-api-key/
      index.ts               # 13.5 provider key validation
    store-api-key/
      index.ts               # 13.5 key encryption + storage
    stripe-webhook/
      index.ts               # 2.3 Stripe event handler
    create-checkout-session/
      index.ts               # 2.3 Stripe checkout creation
    files-zip/
      index.ts               # 8.6 bulk file zip
    generate-embedding/
      index.ts               # Shared embedding utility
```

### Deployment Commands 

```shell
# Deploy all functions
supabase functions deploy

# Deploy a specific function
supabase functions deploy cipher-route

# Set secrets
supabase secrets set OPENROUTER_API_KEY=sk-or-...
supabase secrets set STRIPE_SECRET_KEY=sk_live_...
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...
supabase secrets set API_KEY_ENCRYPTION_SECRET=[32-byte-hex]

# View logs
supabase functions logs cipher-route --tail
```

---

## 19.21 Request/Response Summary 

| Function | Method | Input | Output | Latency Target |
| :---- | :---- | :---- | :---- | :---- |
| `cipher-route` | POST | Message \+ context | SSE token stream | \< 500ms TTFB |
| `cipher-memory` | POST | Message pair | `{ success }` | \< 10s total |
| `cipher-cleanup` | POST | (none) | `{ users_processed }` | \< 5 min |
| `cipher-health` | POST | (none) | `{ personas_processed }` | \< 5 min |
| `search` | POST | Query \+ filters | Ranked results | \< 2s |
| `analytics` | POST | Type \+ interval | Aggregated data | \< 5s |
| `chat-export` | POST | Chat ID \+ format | Download URL | \< 30s |
| `browser-proxy` | GET | URL param | HTML content | \< 5s |
| `browser-extract` | POST | Page content | Extract ID | \< 3s |
| `team-execute` | POST | Team ID \+ input | Run ID \+ status | \< 10 min |
| `validate-api-key` | POST | Provider \+ key | `{ valid }` | \< 5s |
| `store-api-key` | POST | Provider \+ key | Key ID | \< 2s |
| `stripe-webhook` | POST | Stripe event | `{ received }` | \< 3s |
| `create-checkout-session` | POST | Type \+ tier/credits | Checkout URL | \< 3s |
| `files-zip` | POST | File IDs | Download URL | \< 30s |
| `generate-embedding` | POST | Text | Embedding vector | \< 2s |

---

## 19.22 Error Handling Conventions 

All Edge Functions follow a consistent error contract:

### Error Response Shape 

```ts
interface ErrorResponse {
  error: string;        // Human-readable message
  code?: string;        // Machine-readable code (optional)
  details?: any;        // Additional context (optional)
}
```

### HTTP Status Code Usage 

| Status | Meaning | When Used |
| :---- | :---- | :---- |
| 200 | Success | Standard responses |
| 400 | Bad Request | Missing required fields, invalid format |
| 401 | Unauthorized | Missing or invalid JWT, expired token |
| 403 | Forbidden | Tier-gated feature, resource not owned by user |
| 404 | Not Found | Entity doesn't exist or user has no access |
| 429 | Rate Limited | Per-minute, per-day, or concurrent limits hit |
| 500 | Internal Error | Database error, external API failure |
| 502 | Bad Gateway | Upstream model provider error |
| 503 | Service Unavailable | All models unavailable, platform outage |

### Retry Behavior 

Functions that interact with external APIs (model calls, Stripe) implement retry logic:

| External API | Retries | Backoff | Retryable Codes |
| :---- | :---- | :---- | :---- |
| AI model providers | 1 retry | 2s fixed | 429, 502, 503 |
| Stripe | 0 retries | — | (webhook redelivery handles this) |
| OpenRouter embedding | 2 retries | 1s exponential | 429, 502, 503 |

---

## 19.23 Cross-Reference: Functions by Source Part 

| Source Part | Functions Defined |
| :---- | :---- |
| Part 2 | `stripe-webhook`, `create-checkout-session` |
| Part 5 | `chat-export` (cipher-route referenced but defined in Part 14\) |
| Part 6 | `search` |
| Part 8 | `files-zip` |
| Part 10 | `team-execute` |
| Part 11 | `browser-proxy`, `browser-extract` |
| Part 12 | `analytics` |
| Part 13 | `validate-api-key`, `store-api-key` |
| Part 14 | `cipher-route`, `cipher-memory`, `cipher-cleanup`, `cipher-health` |
| Part 16 | Provider routing and streaming (consumed by `cipher-route`) |
| Part 19 | `generate-embedding` (new — shared utility) |

---

*End of Part 19\. Proceed to Part 20: Frontend Component Architecture.*

# PART 20: FRONTEND COMPONENT ARCHITECTURE 

---

This part specifies the complete frontend architecture of aiConnected: the Next.js App Router page tree, the component hierarchy for every screen, all Zustand stores, the React Query (TanStack Query) caching strategy, the Supabase Realtime provider system, custom hooks, TypeScript interfaces, and the full data-fetching contract that connects the client to the REST API and Edge Functions defined in Parts 18–19.

aiConnected is a single-page application rendered client-side within Next.js App Router. Server Components are used for the root layout (font loading, metadata, auth check), but all interactive screens are Client Components that fetch data via the Supabase JS SDK and TanStack Query.

---

## 20.1 Page Structure (Next.js App Router) 

### Route Tree 

```
app/
  layout.tsx                  — Root layout: fonts, providers, auth gate
  page.tsx                    — Home / Dashboard
  chat/
    page.tsx                  — Chat screen (drawer + conversation + right panel)
  search/
    page.tsx                  — Universal search
  spaces/
    page.tsx                  — Instance list (grid)
    [id]/
      page.tsx                — Instance detail (tabs: chats, tasks, files, personas, settings)
  files/
    page.tsx                  — Global file system
  people/
    page.tsx                  — Persona list (grid)
    [id]/
      page.tsx                — Persona detail (tabs: identity, skills, memory, health)
  teams/
    page.tsx                  — Teams list + detail (split view)
  browser/
    page.tsx                  — Browser workspace
  insights/
    page.tsx                  — Analytics dashboard
  settings/
    page.tsx                  — Settings (6 tabs)
  auth/
    login/
      page.tsx                — Login form (email + OAuth)
    signup/
      page.tsx                — Signup form (email + OAuth)
    callback/
      route.ts                — OAuth callback handler (server route)
```

### Root Layout 

The root layout wraps the entire application with font loading, metadata, and the provider stack.

```ts
// app/layout.tsx
import { DM_Sans } from "next/font/google";
import { Providers } from "@/components/providers/Providers";
import "@/styles/globals.css";

const dmSans = DM_Sans({
  subsets: ["latin"],
  weight: ["200", "300", "400", "500", "600", "700"],
  variable: "--font-dm-sans",
});

export const metadata = {
  title: "aiConnected",
  description: "AI collaboration platform with persistent personas",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={dmSans.variable} suppressHydrationWarning>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
```

### Authentication Gate 

All routes except `/auth/*` require authentication. The root layout's `Providers` component includes an auth check that redirects unauthenticated users to `/auth/login`.

```ts
// app/page.tsx (and all non-auth pages)
"use client";

import { AppShell } from "@/components/shell/AppShell";
import { HomeScreen } from "@/components/screens/HomeScreen";

export default function HomePage() {
  return (
    <AppShell>
      <HomeScreen />
    </AppShell>
  );
}
```

Every page follows this pattern: wrap in `AppShell`, render a screen component. The `AppShell` provides the sidebar, top bar, and content area layout. The screen component fills the content area.

---

## 20.2 Provider Stack 

The provider hierarchy wraps the application in the correct dependency order. Each provider is a Client Component.

```ts
// src/components/providers/Providers.tsx
"use client";

import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "./AuthProvider";
import { ThemeProvider } from "./ThemeProvider";
import { RealtimeProvider } from "./RealtimeProvider";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 30_000,           // 30 seconds before refetch
        gcTime: 5 * 60_000,          // 5 minutes garbage collection
        refetchOnWindowFocus: true,
        retry: 1,
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <ThemeProvider>
          <RealtimeProvider>
            {children}
          </RealtimeProvider>
        </ThemeProvider>
      </AuthProvider>
    </QueryClientProvider>
  );
}
```

### Provider Responsibilities 

| Provider | Responsibility | Source |
| :---- | :---- | :---- |
| `QueryClientProvider` | TanStack Query cache, default options | TanStack Query |
| `AuthProvider` | Session state, user profile, auth redirect, onAuthStateChange | Part 2 |
| `ThemeProvider` | Light/dark/system theme, CSS class toggling | Part 1 |
| `RealtimeProvider` | Global Supabase Realtime subscriptions (notifications, instruction\_memory, persona updates) | Part 17 |

### AuthProvider 

```ts
// src/components/providers/AuthProvider.tsx
"use client";

import { createContext, useContext, useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import type { User, Session } from "@supabase/supabase-js";
import type { Profile } from "@/types/database";

interface AuthContextType {
  user: User | null;
  profile: Profile | null;
  session: Session | null;
  isLoading: boolean;
  signOut: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType>({
  user: null, profile: null, session: null, isLoading: true,
  signOut: async () => {},
});

export function useAuth() {
  return useContext(AuthContext);
}

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [profile, setProfile] = useState<Profile | null>(null);
  const [session, setSession] = useState<Session | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const router = useRouter();
  const pathname = usePathname();
  const supabase = createClient();

  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
      setUser(session?.user ?? null);
      if (session?.user) fetchProfile(session.user.id);
      else setIsLoading(false);
    });

    // Listen for auth changes
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      async (event, session) => {
        setSession(session);
        setUser(session?.user ?? null);
        if (session?.user) await fetchProfile(session.user.id);
        else {
          setProfile(null);
          if (!pathname.startsWith("/auth")) router.push("/auth/login");
        }
      }
    );

    return () => subscription.unsubscribe();
  }, []);

  async function fetchProfile(userId: string) {
    const { data } = await supabase
      .from("profiles")
      .select("*")
      .eq("id", userId)
      .single();
    setProfile(data);
    setIsLoading(false);
  }

  async function signOut() {
    await supabase.auth.signOut();
    setUser(null);
    setProfile(null);
    setSession(null);
    router.push("/auth/login");
  }

  // Redirect unauthenticated users
  useEffect(() => {
    if (!isLoading && !user && !pathname.startsWith("/auth")) {
      router.push("/auth/login");
    }
  }, [isLoading, user, pathname]);

  return (
    <AuthContext.Provider value={{ user, profile, session, isLoading, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}
```

### ThemeProvider 

```ts
// src/components/providers/ThemeProvider.tsx
"use client";

import { createContext, useContext, useEffect, useState } from "react";
import { useAuth } from "./AuthProvider";

type ThemeMode = "light" | "dark" | "system";
type ResolvedTheme = "light" | "dark";

interface ThemeContextType {
  mode: ThemeMode;
  resolved: ResolvedTheme;
  setMode: (mode: ThemeMode) => void;
}

const ThemeContext = createContext<ThemeContextType>({
  mode: "system", resolved: "light", setMode: () => {},
});

export function useTheme() {
  return useContext(ThemeContext);
}

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const { profile } = useAuth();
  const [mode, setModeState] = useState<ThemeMode>("system");
  const [resolved, setResolved] = useState<ResolvedTheme>("light");

  // Sync with user preference
  useEffect(() => {
    if (profile?.preferences?.theme) {
      setModeState(profile.preferences.theme);
    }
  }, [profile]);

  // Resolve system preference
  useEffect(() => {
    function resolve() {
      if (mode === "system") {
        const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
        setResolved(systemDark ? "dark" : "light");
      } else {
        setResolved(mode);
      }
    }
    resolve();
    const mql = window.matchMedia("(prefers-color-scheme: dark)");
    mql.addEventListener("change", resolve);
    return () => mql.removeEventListener("change", resolve);
  }, [mode]);

  // Apply to document
  useEffect(() => {
    document.documentElement.classList.toggle("dark", resolved === "dark");
  }, [resolved]);

  function setMode(newMode: ThemeMode) {
    setModeState(newMode);
    // Persist to profile
    // (update handled by settings screen save)
  }

  return (
    <ThemeContext.Provider value={{ mode, resolved, setMode }}>
      {children}
    </ThemeContext.Provider>
  );
}
```

### RealtimeProvider 

Defined in Part 17 (Section 17.5). Subscribes to global channels: user-scoped notifications, instruction\_memory changes, persona updates, and model\_registry changes. The provider creates shared channel subscriptions and invalidates relevant TanStack Query caches when events arrive.

---

## 20.3 State Management 

aiConnected uses three state layers, each with a distinct purpose and scope.

### Layer 1: Server State (TanStack Query) 

All data from Supabase (database rows, RPC results, Edge Function responses) flows through TanStack Query. This provides automatic caching, background refetching, optimistic updates, and deduplication.

**Conventions:**

- Every query uses a `queryKey` array that starts with the entity type  
- Entity-specific queries include the entity ID as the second key element  
- Filter-dependent queries include the filter as a key element  
- All mutations use `onSuccess` to invalidate affected queries  
- Optimistic updates are used for immediate-feedback actions (pin, archive, rename)

### Layer 2: UI State (Zustand) 

Ephemeral client-side state that doesn't persist to the database: which panel is open, which modal is showing, sidebar expand/collapse, active selections. Three Zustand stores manage distinct concerns.

### Layer 3: URL State (Next.js searchParams) 

Shareable view state encoded in the URL: active settings tab, search query, filter selections, instance detail tab.

---

## 20.4 Zustand Stores 

### ui-store 

Controls global UI chrome: sidebar, panels, modals, and toast notifications.

```ts
// src/stores/ui-store.ts
import { create } from "zustand";

interface Toast {
  id: string;
  message: string;
  type: "info" | "success" | "error" | "warning";
  duration?: number;
}

interface UIState {
  // Sidebar
  sidebarExpanded: boolean;
  sidebarHovered: boolean;
  setSidebarExpanded: (expanded: boolean) => void;
  toggleSidebar: () => void;
  setSidebarHovered: (hovered: boolean) => void;

  // Right panel (chat screen)
  rightPanelOpen: boolean;
  rightPanelTab: "participants" | "memories" | "files" | "info";
  setRightPanelOpen: (open: boolean) => void;
  setRightPanelTab: (tab: UIState["rightPanelTab"]) => void;
  toggleRightPanel: () => void;

  // Command palette
  commandPaletteOpen: boolean;
  setCommandPaletteOpen: (open: boolean) => void;

  // Modals
  activeModal: string | null;
  modalProps: Record<string, any>;
  openModal: (modal: string, props?: Record<string, any>) => void;
  closeModal: () => void;

  // Chat drawer (mobile)
  chatDrawerOpen: boolean;
  setChatDrawerOpen: (open: boolean) => void;

  // Toasts
  toasts: Toast[];
  showToast: (message: string, type?: Toast["type"], duration?: number) => void;
  dismissToast: (id: string) => void;

  // Confirmation dialog
  confirmDialog: {
    open: boolean;
    title: string;
    message: string;
    onConfirm: () => void;
  } | null;
  showConfirm: (title: string, message: string, onConfirm: () => void) => void;
  closeConfirm: () => void;
}

export const useUIStore = create<UIState>((set) => ({
  // Sidebar
  sidebarExpanded: true,
  sidebarHovered: false,
  setSidebarExpanded: (expanded) => set({ sidebarExpanded: expanded }),
  toggleSidebar: () => set((s) => ({ sidebarExpanded: !s.sidebarExpanded })),
  setSidebarHovered: (hovered) => set({ sidebarHovered: hovered }),

  // Right panel
  rightPanelOpen: false,
  rightPanelTab: "participants",
  setRightPanelOpen: (open) => set({ rightPanelOpen: open }),
  setRightPanelTab: (tab) => set({ rightPanelTab: tab, rightPanelOpen: true }),
  toggleRightPanel: () => set((s) => ({ rightPanelOpen: !s.rightPanelOpen })),

  // Command palette
  commandPaletteOpen: false,
  setCommandPaletteOpen: (open) => set({ commandPaletteOpen: open }),

  // Modals
  activeModal: null,
  modalProps: {},
  openModal: (modal, props = {}) => set({ activeModal: modal, modalProps: props }),
  closeModal: () => set({ activeModal: null, modalProps: {} }),

  // Chat drawer
  chatDrawerOpen: false,
  setChatDrawerOpen: (open) => set({ chatDrawerOpen: open }),

  // Toasts
  toasts: [],
  showToast: (message, type = "info", duration = 4000) => {
    const id = crypto.randomUUID();
    set((s) => ({ toasts: [...s.toasts, { id, message, type, duration }] }));
    if (duration > 0) {
      setTimeout(() => {
        set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
      }, duration);
    }
  },
  dismissToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),

  // Confirmation
  confirmDialog: null,
  showConfirm: (title, message, onConfirm) =>
    set({ confirmDialog: { open: true, title, message, onConfirm } }),
  closeConfirm: () => set({ confirmDialog: null }),
}));
```

### navigation-store 

Tracks the current screen, active entities, and selection state for multi-select operations.

```ts
// src/stores/navigation-store.ts
import { create } from "zustand";

type Screen = "home" | "chat" | "search" | "spaces" | "files" | "people" | "teams" | "browser" | "insights" | "settings";

interface NavigationState {
  // Active screen (derived from route, used by sidebar highlighting)
  activeScreen: Screen;
  setActiveScreen: (screen: Screen) => void;

  // Active chat
  activeChatId: string | null;
  setActiveChatId: (id: string | null) => void;

  // Active instance
  activeInstanceId: string | null;
  setActiveInstanceId: (id: string | null) => void;

  // Active persona
  activePersonaId: string | null;
  setActivePersonaId: (id: string | null) => void;

  // Multi-select (files, chats, tasks)
  selectedIds: Set<string>;
  toggleSelected: (id: string) => void;
  selectAll: (ids: string[]) => void;
  clearSelection: () => void;
  isSelected: (id: string) => boolean;

  // Chat filters
  chatFilter: "all" | "pinned" | "archived" | "deleted";
  setChatFilter: (filter: NavigationState["chatFilter"]) => void;

  // Instance detail tab
  instanceTab: "chats" | "tasks" | "files" | "personas" | "settings";
  setInstanceTab: (tab: NavigationState["instanceTab"]) => void;

  // Persona detail tab
  personaTab: "identity" | "skills" | "memory" | "health";
  setPersonaTab: (tab: NavigationState["personaTab"]) => void;

  // Settings tab
  settingsTab: "general" | "personas" | "models" | "privacy" | "keys" | "types";
  setSettingsTab: (tab: NavigationState["settingsTab"]) => void;
}

export const useNavigationStore = create<NavigationState>((set, get) => ({
  activeScreen: "home",
  setActiveScreen: (screen) => set({ activeScreen: screen }),

  activeChatId: null,
  setActiveChatId: (id) => set({ activeChatId: id }),

  activeInstanceId: null,
  setActiveInstanceId: (id) => set({ activeInstanceId: id }),

  activePersonaId: null,
  setActivePersonaId: (id) => set({ activePersonaId: id }),

  selectedIds: new Set(),
  toggleSelected: (id) => set((s) => {
    const next = new Set(s.selectedIds);
    next.has(id) ? next.delete(id) : next.add(id);
    return { selectedIds: next };
  }),
  selectAll: (ids) => set({ selectedIds: new Set(ids) }),
  clearSelection: () => set({ selectedIds: new Set() }),
  isSelected: (id) => get().selectedIds.has(id),

  chatFilter: "all",
  setChatFilter: (filter) => set({ chatFilter: filter }),

  instanceTab: "chats",
  setInstanceTab: (tab) => set({ instanceTab: tab }),

  personaTab: "identity",
  setPersonaTab: (tab) => set({ personaTab: tab }),

  settingsTab: "general",
  setSettingsTab: (tab) => set({ settingsTab: tab }),
}));
```

### browser-store 

State for the browser workspace viewport and tabs.

```ts
// src/stores/browser-store.ts
import { create } from "zustand";

type BrowserViewMode = "browse" | "split" | "chat";

interface BrowserState {
  // Session
  activeSessionId: string | null;
  setActiveSessionId: (id: string | null) => void;

  // View mode
  viewMode: BrowserViewMode;
  setViewMode: (mode: BrowserViewMode) => void;

  // Tabs
  activeTabId: string | null;
  setActiveTabId: (id: string | null) => void;

  // Address bar
  addressBarValue: string;
  setAddressBarValue: (value: string) => void;
  isAddressBarFocused: boolean;
  setAddressBarFocused: (focused: boolean) => void;

  // Viewport state
  isLoading: boolean;
  setIsLoading: (loading: boolean) => void;
  pageTitle: string;
  setPageTitle: (title: string) => void;

  // Selection mode
  selectionMode: boolean;
  setSelectionMode: (on: boolean) => void;
  selectedText: string;
  setSelectedText: (text: string) => void;
}

export const useBrowserStore = create<BrowserState>((set) => ({
  activeSessionId: null,
  setActiveSessionId: (id) => set({ activeSessionId: id }),

  viewMode: "split",
  setViewMode: (mode) => set({ viewMode: mode }),

  activeTabId: null,
  setActiveTabId: (id) => set({ activeTabId: id }),

  addressBarValue: "",
  setAddressBarValue: (value) => set({ addressBarValue: value }),
  isAddressBarFocused: false,
  setAddressBarFocused: (focused) => set({ isAddressBarFocused: focused }),

  isLoading: false,
  setIsLoading: (loading) => set({ isLoading: loading }),
  pageTitle: "",
  setPageTitle: (title) => set({ pageTitle: title }),

  selectionMode: false,
  setSelectionMode: (on) => set({ selectionMode: on }),
  selectedText: "",
  setSelectedText: (text) => set({ selectedText: text }),
}));
```

---

## 20.5 TanStack Query Key Registry 

Every query key used across the application. This registry ensures consistent naming, prevents key collisions, and documents invalidation dependencies.

| Query Key | Fetches | Used By Screen(s) | Invalidated By |
| :---- | :---- | :---- | :---- |
| `["profile"]` | Current user profile | Shell (sidebar, top bar), Settings | Profile update |
| `["dashboard-stats"]` | Overview stat cards | Home | File upload, chat create, persona create |
| `["recent-activity"]` | Activity log entries | Home | Any entity mutation |
| `["chats", userId]` | Chat list (with filters) | Chat drawer | Chat create, rename, delete, archive |
| `["deleted-chats", userId]` | Soft-deleted chats | Chat drawer (Trash filter) | Chat restore, permanent delete |
| `["chat-list"]` | Simplified chat list | Chat drawer | Chat create, rename, Realtime insert |
| `["messages", chatId]` | Messages for a chat | Chat conversation | Message send, Realtime insert |
| `["chat-participants", chatId]` | Personas in a chat | Chat right panel | Add/remove participant |
| `["chat-checkpoints", chatId]` | Nav panel checkpoints | Chat nav panel | cipher-memory checkpoint creation |
| `["search", query, filter, scope]` | Search results | Search screen | (not invalidated — refetches on param change) |
| `["instances"]` | All user instances | Spaces grid, sidebar, selectors | Instance create, update, delete |
| `["instance-chats", instanceId]` | Chats in an instance | Instance detail: Chats tab | Chat create, move to instance |
| `["instance-files", instanceId]` | Files in an instance | Instance detail: Files tab | File upload, move |
| `["instance-personas", instanceId]` | Personas in an instance | Instance detail: Personas tab | Add/remove persona |
| `["global-files"]` | All user files | Files screen | Upload, delete, rename, move |
| `["signed-url", storagePath]` | Signed download URL | File preview | (staleTime: 50 minutes) |
| `["personas"]` | All user personas | People grid, selectors | Persona create, update, delete |
| `["persona-memories", personaId, filter]` | Memories for a persona | Persona detail: Memory tab | Memory CRUD, cipher-memory extraction |
| `["teams", filter]` | Team list | Teams screen | Team create, update, delete |
| `["team-run-status", teamId]` | Latest run status | Teams detail | team-execute completion |
| `["browser-sessions-active"]` | Active browser sessions | Browser workspace | Session create, delete |
| `["browser-tabs", sessionId]` | Tabs in a session | Browser tab strip | Tab create, close, reorder |
| `["browser-history"]` | Browsing history | Browser history panel | Navigation |
| `["browser-extracts", sessionId]` | Extracts from a session | Browser extracts panel | Extract save |
| `["analytics", type, timeRange]` | Analytics data | Insights screen | (staleTime: 5 minutes) |
| `["api-keys"]` | User API keys | Settings: API Keys tab | Key add, remove, validate |
| `["model-registry"]` | Available models | Model selector, Settings | (staleTime: 1 hour, server-managed) |
| `["model-roles"]` | Role assignments | Settings: Models tab, Cipher | Role assignment update |
| `["instruction-memory"]` | Instruction rules | Settings: Privacy tab | Rule add, toggle, delete, Realtime |
| `["instance-types"]` | Instance type templates | Settings: Types tab, instance create | Type create, update, delete |
| `["instance-type-counts"]` | Instances per type | Settings: Types tab | Instance create, type assign |
| `["flagged-memories"]` | Conflict-tagged memories | Settings: Privacy tab | Memory resolve, deactivate |

---

## 20.6 Custom Hooks 

### Data Hooks 

These hooks wrap TanStack Query calls and provide typed, reusable data access.

```ts
// src/hooks/use-profile.ts
export function useProfile() {
  const supabase = createClient();
  const { user } = useAuth();
  return useQuery({
    queryKey: ["profile"],
    queryFn: async () => {
      const { data } = await supabase.from("profiles").select("*").eq("id", user!.id).single();
      return data as Profile;
    },
    enabled: !!user,
  });
}
```

```ts
// src/hooks/use-chats.ts
export function useChats(filter: ChatFilter = "all") {
  const supabase = createClient();
  const { user } = useAuth();
  return useQuery({
    queryKey: ["chats", user?.id, filter],
    queryFn: async () => {
      let query = supabase
        .from("chats")
        .select("*, chat_participants(persona_id, is_primary, personas(name, avatar, color, status))")
        .eq("user_id", user!.id)
        .order("updated_at", { ascending: false });

      switch (filter) {
        case "all": query = query.is("deleted_at", null).eq("archived", false); break;
        case "pinned": query = query.eq("pinned", true).is("deleted_at", null); break;
        case "archived": query = query.eq("archived", true).is("deleted_at", null); break;
        case "deleted": query = query.not("deleted_at", "is", null); break;
      }

      const { data } = await query;
      return data ?? [];
    },
    enabled: !!user,
  });
}
```

```ts
// src/hooks/use-messages.ts
export function useMessages(chatId: string | null) {
  const supabase = createClient();
  return useQuery({
    queryKey: ["messages", chatId],
    queryFn: async () => {
      const { data } = await supabase
        .from("messages")
        .select("*")
        .eq("chat_id", chatId!)
        .is("deleted_at", null)
        .order("created_at", { ascending: true });
      return data ?? [];
    },
    enabled: !!chatId,
  });
}
```

```ts
// src/hooks/use-personas.ts
export function usePersonas() {
  const supabase = createClient();
  const { user } = useAuth();
  return useQuery({
    queryKey: ["personas"],
    queryFn: async () => {
      const { data } = await supabase
        .from("personas")
        .select("*, persona_skills(count)")
        .eq("user_id", user!.id)
        .neq("status", "archived")
        .order("updated_at", { ascending: false });
      return data ?? [];
    },
    enabled: !!user,
  });
}
```

```ts
// src/hooks/use-instances.ts
export function useInstances() {
  const supabase = createClient();
  const { user } = useAuth();
  return useQuery({
    queryKey: ["instances"],
    queryFn: async () => {
      const { data } = await supabase
        .from("instances")
        .select("*, instance_personas(persona_id, personas(name, avatar, color))")
        .eq("user_id", user!.id)
        .is("deleted_at", null)
        .order("updated_at", { ascending: false });
      return data ?? [];
    },
    enabled: !!user,
  });
}
```

```ts
// src/hooks/use-teams.ts
export function useTeams(filter: string = "all") {
  const supabase = createClient();
  const { user } = useAuth();
  return useQuery({
    queryKey: ["teams", filter],
    queryFn: async () => {
      const { data } = await supabase
        .from("teams")
        .select("*, team_members(persona_id, role, personas(name, avatar, color))")
        .eq("user_id", user!.id)
        .order("updated_at", { ascending: false });
      return data ?? [];
    },
    enabled: !!user,
  });
}
```

```ts
// src/hooks/use-model-registry.ts
export function useModelRegistry() {
  const supabase = createClient();
  return useQuery({
    queryKey: ["model-registry"],
    queryFn: async () => {
      const { data } = await supabase
        .from("model_registry")
        .select("*")
        .eq("status", "active")
        .order("sort_order", { ascending: true });
      return data ?? [];
    },
    staleTime: 60 * 60_000,  // 1 hour cache
  });
}
```

### Operation Hooks 

```ts
// src/hooks/use-chat-operations.ts
export function useChatOperations() {
  const supabase = createClient();
  const queryClient = useQueryClient();
  const { user } = useAuth();

  const createChat = useMutation({
    mutationFn: async (opts: { title?: string; instanceId?: string; personaId?: string }) => {
      const { data: chat } = await supabase
        .from("chats")
        .insert({ user_id: user!.id, title: opts.title ?? "New Chat", instance_id: opts.instanceId })
        .select()
        .single();

      if (chat && opts.personaId) {
        await supabase.from("chat_participants").insert({
          chat_id: chat.id, persona_id: opts.personaId, is_primary: true,
        });
      }
      return chat;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["chats"] });
      queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] });
    },
  });

  const renameChat = useMutation({
    mutationFn: async ({ chatId, title }: { chatId: string; title: string }) => {
      await supabase.from("chats").update({ title }).eq("id", chatId);
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chats"] }),
  });

  const deleteChat = useMutation({
    mutationFn: async (chatId: string) => {
      await supabase.from("chats").update({ deleted_at: new Date().toISOString() }).eq("id", chatId);
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chats"] }),
  });

  const pinChat = useMutation({
    mutationFn: async ({ chatId, pinned }: { chatId: string; pinned: boolean }) => {
      await supabase.from("chats").update({ pinned }).eq("id", chatId);
    },
    onMutate: async ({ chatId, pinned }) => {
      // Optimistic update
      await queryClient.cancelQueries({ queryKey: ["chats"] });
      queryClient.setQueriesData({ queryKey: ["chats"] }, (old: any[]) =>
        old?.map((c) => (c.id === chatId ? { ...c, pinned } : c))
      );
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: ["chats"] }),
  });

  const archiveChat = useMutation({
    mutationFn: async ({ chatId, archived }: { chatId: string; archived: boolean }) => {
      await supabase.from("chats").update({ archived }).eq("id", chatId);
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chats"] }),
  });

  return { createChat, renameChat, deleteChat, pinChat, archiveChat };
}
```

### Streaming Hook 

```ts
// src/hooks/use-streaming-response.ts
// Source: Part 16, Section 16.3

export function useStreamingResponse(chatId: string | null) {
  const [streamingContent, setStreamingContent] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);
  const [streamingMeta, setStreamingMeta] = useState<StreamDoneEvent | null>(null);
  const abortRef = useRef<AbortController | null>(null);
  const queryClient = useQueryClient();

  async function sendMessage(content: string, options?: {
    directedPersonaId?: string;
    modelOverride?: string;
    attachments?: any[];
  }) {
    if (!chatId) return;

    setIsStreaming(true);
    setStreamingContent("");
    setStreamingMeta(null);

    abortRef.current = new AbortController();

    const supabase = createClient();
    const { data: { session } } = await supabase.auth.getSession();

    const response = await fetch(`${SUPABASE_URL}/functions/v1/cipher-route`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${session?.access_token}`,
      },
      body: JSON.stringify({
        chat_id: chatId,
        content,
        directed_persona_id: options?.directedPersonaId,
        model_override: options?.modelOverride,
        attachments: options?.attachments,
      }),
      signal: abortRef.current.signal,
    });

    const reader = response.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop() ?? "";

      for (const line of lines) {
        if (!line.startsWith("data: ")) continue;
        const json = line.slice(6);
        if (json === "[DONE]") continue;

        try {
          const event = JSON.parse(json);
          switch (event.type) {
            case "token":
              setStreamingContent((prev) => prev + event.content);
              break;
            case "done":
              setStreamingMeta(event);
              setIsStreaming(false);
              queryClient.invalidateQueries({ queryKey: ["messages", chatId] });
              break;
            case "error":
              setIsStreaming(false);
              break;
          }
        } catch {}
      }
    }
  }

  function stopStream() {
    abortRef.current?.abort();
    setIsStreaming(false);
  }

  return { sendMessage, stopStream, streamingContent, isStreaming, streamingMeta };
}
```

### Utility Hooks 

```ts
// src/hooks/use-mobile.ts
export function useMobile() {
  const [mobile, setMobile] = useState(false);
  useEffect(() => {
    const check = () => setMobile(window.innerWidth < 768);
    check();
    window.addEventListener("resize", check);
    return () => window.removeEventListener("resize", check);
  }, []);
  return mobile;
}

// src/hooks/use-keyboard-shortcuts.ts
// Source: Part 3, Section 3.6
export function useKeyboardShortcuts(shortcuts: Record<string, () => void>) {
  useEffect(() => {
    function handler(e: KeyboardEvent) {
      const meta = e.metaKey || e.ctrlKey;
      const key = e.key.toLowerCase();
      const combo = meta ? `cmd+${key}` : key;
      if (shortcuts[combo]) {
        e.preventDefault();
        shortcuts[combo]();
      }
    }
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [shortcuts]);
}

// src/hooks/use-tier-gates.ts
export function useTierGates() {
  const { profile } = useAuth();
  const tier = profile?.tier ?? "free";
  return getTierLimits(tier);  // From Part 2 tier-gates utility
}
```

---

## 20.7 Component Hierarchy 

### Application Shell 

The shell wraps every page. It renders the sidebar, top bar, content area, command palette overlay, modals, and toast container.

```
<AppShell>
  ├── <Sidebar>
  │   ├── <SidebarHeader>             — Logo + brand name
  │   ├── <SidebarNav>                — Navigation items (Home, Chat, Search, etc.)
  │   │   └── <SidebarNavItem>[]      — Icon + label, active highlight
  │   ├── <SidebarRecent>             — Recent chats (3 items, expanded only)
  │   │   └── <SidebarChatItem>[]
  │   └── <SidebarFooter>             — User avatar + name + tier badge
  │       └── <SidebarUserMenu>       — Theme toggle, sign out
  │
  ├── <MainArea>
  │   ├── <TopBar>
  │   │   ├── <TopBarTitle>           — Screen title or breadcrumb
  │   │   ├── <TopBarSearch>          — ⌘K trigger (click opens command palette)
  │   │   └── <TopBarActions>
  │   │       ├── <NotificationBell>
  │   │       └── <UserAvatar>
  │   │
  │   └── <ContentArea>               — {children} from page.tsx
  │
  ├── <CommandPalette>                — Overlay: global search + actions (⌘K)
  ├── <ModalContainer>                — Renders active modal from ui-store
  ├── <ToastContainer>                — Renders toasts from ui-store
  └── <ConfirmDialog>                 — Confirmation overlay from ui-store
```

### NotificationDropdown 

```
<NotificationDropdown>
  ├── <DropdownHeader>                — "Notifications" + "Mark all read" link
  ├── <NotificationList>
  │   └── <NotificationItem>[]        — Icon + title + body + time + unread dot
  │       └── onClick → navigate to action_url
  └── <DropdownFooter>                — "View all" link → notification history
```

### CommandPalette 

```
<CommandPalette>                      — Full-screen overlay, dimmed bg
  ├── <PaletteInput>                  — Search input (auto-focused)
  ├── <PaletteResults>
  │   ├── <PaletteGroup label="Actions">
  │   │   └── <PaletteItem>[]         — "New Chat", "New Persona", etc.
  │   ├── <PaletteGroup label="Chats">
  │   │   └── <PaletteItem>[]         — Matching chat titles
  │   ├── <PaletteGroup label="Personas">
  │   │   └── <PaletteItem>[]         — Matching persona names
  │   └── <PaletteGroup label="Instances">
  │       └── <PaletteItem>[]         — Matching instance names
  └── <PaletteFooter>                 — Keyboard hints: ↑↓ navigate, Enter select, Esc close
```

---

## 20.8 Screen Components 

### Home Screen 

```
<HomeScreen>
  ├── <WelcomeHeader>                 — "Good morning, {name}" + date
  ├── <StatCardsGrid>                 — 4 cards: Active Personas, Instances, Chats, Memories
  │   └── <StatCard>[]                — Icon + value + label + trend
  ├── <QuickActions>                  — New Chat, Create Persona, Search buttons
  ├── <ActivityFeed>
  │   └── <ActivityItem>[]            — Avatar + description + timestamp
  └── <RecentChats>                   — 5 most recent chats with last message preview
      └── <RecentChatItem>[]
```

Data: `useDashboardStats()`, `useQuery(["recent-activity"])`, `useChats("all")`

### Chat Screen 

```
<ChatScreen>
  ├── <ChatDrawer>                    — Left panel: chat list
  │   ├── <DrawerHeader>              — Filter tabs + New Chat button
  │   │   └── <FilterBar>             — All | Pinned | Archived | Deleted
  │   ├── <ChatList>
  │   │   └── <ChatListItem>[]        — Title + persona avatars + time + pin icon
  │   │       └── <ChatContextMenu>   — Rename, Pin, Archive, Move, Delete
  │   └── <DrawerFooter>              — Instance filter dropdown
  │
  ├── <ConversationArea>
  │   ├── <ConversationHeader>        — Chat title + participant chips + menu
  │   │   ├── <ParticipantChips>      — Persona avatar + name pills
  │   │   └── <ChatHeaderMenu>        — Export, Move to Instance, Delete
  │   ├── <MessageList>               — Scrollable message container
  │   │   ├── <Message>[]             — Individual messages
  │   │   │   ├── <MessageAvatar>     — User or persona avatar
  │   │   │   ├── <MessageContent>    — Markdown-rendered content
  │   │   │   ├── <MessageMeta>       — Time + model badge + routing note
  │   │   │   └── <MessageActions>    — Pin, Copy, Delete (hover)
  │   │   ├── <TypingIndicator>       — Persona typing animation
  │   │   └── <StreamingMessage>      — In-progress streaming content
  │   ├── <MessageComposer>
  │   │   ├── <ComposerInput>         — Textarea with auto-resize
  │   │   ├── <AttachmentBar>         — File previews (if attached)
  │   │   ├── <ComposerToolbar>
  │   │   │   ├── <PersonaSelector>   — @mention target picker
  │   │   │   ├── <ModelSelector>     — Model override chip
  │   │   │   ├── <AttachButton>      — File attachment
  │   │   │   └── <VoiceButton>       — Voice input (future)
  │   │   └── <SendButton>            — ArrowUp icon, accent bg
  │   └── <ChatNavPanel>              — Collapsible: conversation segments
  │       └── <NavSection>[]          — Checkpoint summaries (from memory_checkpoints)
  │
  └── <RightPanel>                    — Collapsible: contextual info
      ├── <RightPanelTabs>            — Participants | Memories | Files | Info
      ├── <ParticipantsView>
      │   └── <ParticipantCard>[]     — Persona name, role, skills, status
      ├── <MemoriesView>
      │   └── <MemoryItem>[]          — Type icon + content + confidence badge
      ├── <FilesView>
      │   └── <FileItem>[]            — File name + type icon + size
      └── <InfoView>                  — Chat metadata, export history, timestamps
```

Data: `useChats()`, `useMessages(chatId)`, `useStreamingResponse(chatId)`, `useChatRealtime(chatId)`, `useTypingIndicator(chatId)`

### Search Screen 

```
<SearchScreen>
  ├── <SearchHeader>
  │   ├── <SearchInput>               — Large search field, auto-focused
  │   └── <SearchFilters>             — Type filter chips: All, Chats, Messages, Files, etc.
  ├── <SearchResults>
  │   └── <SearchResultItem>[]        — Type icon + title + snippet + score + actions
  │       └── onClick → navigate to entity
  ├── <SearchEmpty>                   — Empty state when no query
  └── <SearchHistory>                 — Recent searches (below input when empty)
      └── <HistoryItem>[]             — Query text + timestamp + result count
```

Data: `useSearch(query, filter, scope)`, `useQuery(["search-history"])`

### Spaces Screen (Instance List) 

```
<SpacesScreen>
  ├── <SpacesHeader>                  — "Instances" title + New Instance button + view toggle
  ├── <SpacesGrid>
  │   └── <InstanceCard>[]            — Icon + name + type badge + persona count + chat count
  │       └── onClick → navigate to /spaces/[id]
  └── <SpacesEmpty>                   — Empty state with "Create your first Instance"
```

Data: `useInstances()`

### Instance Detail Screen 

```
<InstanceDetailScreen>
  ├── <InstanceHeader>
  │   ├── <InstanceIcon>              — Colored icon (Lucide)
  │   ├── <InstanceTitle>             — Editable name
  │   ├── <InstanceDescription>       — Editable description
  │   └── <InstanceActions>           — Archive, Delete, Settings
  ├── <InstanceTabs>                  — Chats | Tasks | Files | Personas | Settings
  ├── <InstanceChatsTab>
  │   └── <ChatList>                  — Chats scoped to this instance
  ├── <InstanceTasksTab>
  │   ├── <TaskBoard>                 — Kanban: Todo | In Progress | Done | Blocked
  │   │   └── <TaskColumn>
  │   │       └── <TaskCard>[]        — Title + priority badge + assigned persona
  │   └── <TaskCreateRow>             — Inline task creation
  ├── <InstanceFilesTab>
  │   └── <FileGrid>                  — Files scoped to this instance
  ├── <InstancePersonasTab>
  │   ├── <AssignedPersonas>          — Grid of assigned personas
  │   │   └── <PersonaChip>[]
  │   └── <AddPersonaButton>          — Opens persona selector
  └── <InstanceSettingsTab>
      ├── <TypeSelector>              — Instance type (General, Project, etc.)
      ├── <ColorPicker>               — Instance color
      └── <CustomSettingsForm>        — Type-specific settings fields
```

Data: `useInstances()`, `useQuery(["instance-chats", id])`, `useQuery(["instance-files", id])`, `useQuery(["instance-personas", id])`

### Files Screen 

```
<FilesScreen>
  ├── <FilesHeader>
  │   ├── <FilesTitle>                — "Files" + storage usage bar
  │   ├── <FilesToolbar>              — Upload button + view toggle (grid/list) + sort
  │   └── <FilesBreadcrumb>           — Current folder path
  ├── <FilesContent>
  │   ├── <FileGrid>                  — Grid of file cards
  │   │   └── <FileCard>[]            — Preview + name + size + date
  │   │       └── <FileContextMenu>   — Download, Rename, Move, Tag, Delete
  │   └── <FileList>                  — Table: name, type, size, date, instance
  │       └── <FileRow>[]
  ├── <BulkActionBar>                 — Appears when files selected: Download, Move, Delete
  ├── <FilePreviewPanel>              — Slide-over: preview selected file
  │   ├── <PreviewHeader>             — Name + download + close
  │   └── <PreviewContent>            — Image, PDF, text, code, or placeholder
  └── <UploadDropzone>                — Full-screen overlay on drag-over
```

Data: `useQuery(["global-files"])`, `useQuery(["signed-url", path])`

### People Screen (Persona List) 

```
<PeopleScreen>
  ├── <PeopleHeader>                  — "Personas" + New Persona button + filter
  ├── <PersonaGrid>
  │   └── <PersonaCard>[]             — Avatar + name + role + status dot + skill count
  │       └── onClick → navigate to /people/[id]
  └── <PeopleEmpty>                   — "Create your first Persona" + template carousel
```

Data: `usePersonas()`

### Persona Detail Screen 

```
<PersonaDetailScreen>
  ├── <PersonaHeader>
  │   ├── <PersonaAvatar>             — Large avatar (64px)
  │   ├── <PersonaName>               — Editable name + role
  │   ├── <PersonaStatus>             — Status dot + label
  │   └── <PersonaActions>            — Archive, Reset, Delete
  ├── <PersonaTabs>                   — Identity | Skills | Memory | Health
  ├── <IdentityTab>
  │   ├── <IdentityForm>              — Name, role, purpose, personality, voice fields
  │   └── <BoundariesSection>
  │       ├── <WillDo>                — Green-bordered items
  │       ├── <WontDo>                — Red-bordered items
  │       └── <Escalation>            — Yellow-bordered items
  ├── <SkillsTab>
  │   ├── <SkillsList>                — Grouped by category
  │   │   └── <SkillItem>[]           — Name + level badge + temporary marker
  │   └── <AddSkillButton>
  ├── <MemoryTab>
  │   ├── <MemoryFilters>             — Type filter + search + active/inactive toggle
  │   ├── <MemoryList>
  │   │   └── <MemoryItem>[]          — Type icon + content + confidence + tags + source link
  │   │       └── <MemoryActions>     — Edit, Deactivate, Delete
  │   └── <MemoryGraph>               — Visual graph of memory_edges (future)
  └── <HealthTab>
      ├── <HealthOverview>            — Memory stability %, drift level, mood
      └── <HealthTimeline>            — Snapshots over time (line chart)
          └── <HealthSnapshot>[]
```

Data: `usePersonas()`, `useQuery(["persona-memories", personaId, filter])`

### Teams Screen 

```
<TeamsScreen>
  ├── <TeamsHeader>                   — "Teams" + New Team button
  ├── <TeamsList>
  │   └── <TeamCard>[]                — Name + strategy badge + member avatars + last run
  │       └── onClick → expand detail
  ├── <TeamDetail>                    — Expanded view (split panel or full screen)
  │   ├── <TeamHeader>                — Name + description + strategy selector
  │   ├── <TeamMembers>               — Drag-reorderable member list
  │   │   └── <TeamMemberRow>[]       — Persona avatar + name + role selector + order
  │   ├── <TeamExecutionPlan>         — Visual DAG or ordered list of steps
  │   └── <TeamRunHistory>
  │       └── <RunCard>[]             — Status + duration + expand for step details
  │           └── <RunStepDetail>[]   — Persona + input/output + tokens + time
  └── <TeamsEmpty>                    — "Teams help personas work together" explainer
```

Data: `useTeams()`, `useQuery(["team-run-status", teamId])`

### Browser Screen 

```
<BrowserScreen>
  ├── <BrowserToolbar>
  │   ├── <BackForwardButtons>
  │   ├── <AddressBar>                — URL input + refresh + go button
  │   └── <ViewSwitcher>              — Browse | Split | Chat mode toggle
  ├── <BrowserTabStrip>
  │   └── <BrowserTab>[]              — Favicon + title + close button
  ├── <BrowserContent>
  │   ├── <BrowserViewport>           — iframe loading proxied page
  │   │   └── <ViewportOverlay>       — Selection mode highlights
  │   └── <BrowserChatPanel>          — Side panel for persona chat (split/chat mode)
  │       ├── <BrowserChatMessages>
  │       └── <BrowserChatComposer>
  ├── <FloatingPersonaBar>            — Floating: active persona selector
  └── <ExtractPanel>                  — Slide-over: save extracted content
      ├── <ExtractPreview>            — Content preview
      ├── <ExtractOptions>            — Format selector + instance target + tags
      └── <ExtractSaveButton>
```

Data: `useBrowserStore()`, `useQuery(["browser-sessions-active"])`, `useQuery(["browser-tabs", sessionId])`, `useBrowserRealtime(sessionId)`

### Insights Screen 

```
<InsightsScreen>
  ├── <InsightsHeader>                — "Insights" + time range selector (7d/30d/90d/1y)
  ├── <InsightsTabs>                  — Overview | Personas | Models | Memory
  ├── <OverviewTab>
  │   ├── <StatCardsRow>              — Conversations, Messages, Active Personas, Storage
  │   ├── <DailyActivityChart>        — Line/bar chart: messages per day
  │   ├── <TopModelCard>              — Most-used model + usage percentage
  │   └── <MostActivePersonaCard>
  ├── <PersonasTab>
  │   └── <PersonaPerformanceTable>   — Table: persona, messages, chats, memories, skills, last active
  ├── <ModelsTab>
  │   ├── <ModelUsageChart>           — Donut chart: usage distribution
  │   └── <ModelCostTable>            — Table: model, calls, tokens, cost, BYOK flag
  └── <MemoryTab>
      ├── <MemoryOverview>            — Total active, by type distribution, conflicts, avg confidence
      └── <MemoryGrowthChart>         — Line chart: memories over time
```

Data: `useQuery(["analytics", type, timeRange])`

### Settings Screen 

```
<SettingsScreen>
  ├── <SettingsHeader>                — "Settings" title
  ├── <SettingsTabs>                  — General | Personas | Models | Privacy | API Keys | Types
  ├── <GeneralTab>
  │   ├── <AccountSection>            — Display name, email, avatar
  │   ├── <BillingSection>            — Current plan, usage, upgrade button
  │   ├── <AppearanceSection>         — Theme selector (light/dark/system)
  │   └── <PreferencesSection>        — Voice tone, auto-rename, suggest moves, cleanup
  ├── <PersonasTab>
  │   └── <PersonaDefaultsForm>       — Default personality, voice, model preferences
  ├── <ModelsTab>
  │   ├── <RoleAssignmentsTable>      — 7 roles × primary + fallback model selectors
  │   │   └── <RoleAssignmentRow>[]   — Role name + ModelPicker (primary) + ModelPicker (fallback)
  │   └── <ModelRegistryList>         — Read-only list of all available models
  ├── <PrivacyTab>
  │   ├── <DataRetentionSettings>     — Cleanup frequency, auto-delete thresholds
  │   ├── <InstructionMemoryList>     — Detected rules with toggle + delete
  │   │   └── <InstructionItem>[]     — Rule text + scope badge + active toggle
  │   └── <MemoryConflictsSection>    — Flagged conflicts requiring resolution
  ├── <APIKeysTab>
  │   ├── <ConnectedKeysList>         — One row per provider with status + hint + validate/remove
  │   │   └── <KeyRow>[]              — Provider icon + hint + status badge + actions
  │   └── <AddKeyButton>              — Opens key input modal
  └── <TypesTab>                      — Premium+
      ├── <TypesList>
      │   └── <TypeCard>[]            — Name + icon + instance count + edit/delete
      └── <CreateTypeButton>
```

Data: `useProfile()`, `useQuery(["model-roles"])`, `useQuery(["api-keys"])`, `useQuery(["instruction-memory"])`, `useQuery(["instance-types"])`

---

## 20.9 Shared Component Library 

### Atomic Components 

Components used across multiple screens. Defined in Part 1 (Section 1.4), specified here with full interfaces.

```ts
// src/components/atoms/Avatar.tsx
interface AvatarProps {
  name: string;
  size?: number;            // Default: 32
  color?: string;           // Background color override
  className?: string;
}
// Renders a circle with surfaceAlt bg (or color), centered first letter at 36% of diameter

// src/components/atoms/StatusDot.tsx
interface StatusDotProps {
  status: "active" | "idle" | "sleeping" | "archived";
  size?: number;            // Default: 7
}
// Renders a colored circle using dot theme tokens

// src/components/atoms/MemoryTypeIcon.tsx
interface MemoryTypeIconProps {
  type: "decision" | "fact" | "preference" | "skill" | "procedure";
  size?: number;            // Default: 13
}
// Maps to Lucide icons: decision→GitBranch, fact→Brain, preference→Heart, skill→Zap, procedure→List

// src/components/atoms/FilterChip.tsx
interface FilterChipProps {
  label: string;
  active: boolean;
  count?: number;
  onClick: () => void;
}
// Pill-shaped chip: surfaceAlt bg (inactive), accent bg (active), 10px text

// src/components/atoms/TierBadge.tsx
interface TierBadgeProps {
  tier: "free" | "plus" | "premium" | "pro";
}
// Pill badge: 9px uppercase, tier-specific colors

// src/components/atoms/ConfidenceBadge.tsx
interface ConfidenceBadgeProps {
  confidence: "strong" | "medium" | "weak";
}
// Pill: green (strong), amber (medium), red (weak), 9px text

// src/components/atoms/ModelBadge.tsx
interface ModelBadgeProps {
  modelId: string;
  size?: "sm" | "md";
}
// Displays abbreviated model name (e.g., "Sonnet 4.5") with Cpu icon
```

### Composite Components 

```ts
// src/components/shared/EmptyState.tsx
interface EmptyStateProps {
  icon: React.ComponentType;
  title: string;
  description: string;
  action?: {
    label: string;
    onClick: () => void;
  };
}
// Centered: icon (28px, textFaint) + title (14px, text) + description (12px, textMuted) + optional button

// src/components/shared/ConfirmDialog.tsx
interface ConfirmDialogProps {
  open: boolean;
  title: string;
  message: string;
  confirmLabel?: string;
  confirmVariant?: "default" | "destructive";
  onConfirm: () => void;
  onCancel: () => void;
}

// src/components/shared/ContextMenu.tsx
interface ContextMenuItem {
  label: string;
  icon?: React.ComponentType;
  onClick: () => void;
  variant?: "default" | "destructive";
  disabled?: boolean;
}
interface ContextMenuProps {
  items: ContextMenuItem[];
  trigger: React.ReactNode;
}

// src/components/shared/LoadingSkeleton.tsx
interface SkeletonProps {
  variant: "card" | "list-item" | "text" | "avatar" | "stat";
  count?: number;
}
// Renders pulsing placeholder shapes matching each variant's dimensions
```

### Modal Components 

```ts
// src/components/modals/ExportModal.tsx
// Source: Part 5, Section 5.7
// Props: { chatId: string }
// Renders: format selector (tier-gated), scope selector, passphrase input (encrypted_zip), export button

// src/components/modals/PricingModal.tsx
// Source: Part 2, Section 2.3
// Renders: pricing table with 4 tiers, feature comparison, CTA buttons → Stripe Checkout

// src/components/modals/OnboardingModal.tsx
// Source: Part 21 (outlined)
// Renders: multi-step onboarding wizard (welcome, create first persona, create first instance)

// src/components/modals/ShortcutsModal.tsx
// Source: Part 3, Section 3.6
// Renders: keyboard shortcut reference grid

// src/components/modals/CreatePersonaModal.tsx
// Source: Part 9
// Renders: template selector → identity form → skill assignment → review → create

// src/components/modals/CreateInstanceModal.tsx
// Source: Part 7
// Renders: name + description + type selector + persona assignment → create

// src/components/modals/CreateTeamModal.tsx
// Source: Part 10
// Renders: name + strategy selector + member assignment → create
```

---

## 20.10 Data Flow Patterns 

### Pattern 1: Standard CRUD (Client → PostgREST → RLS) 

Used for all user-initiated mutations: creating chats, renaming personas, updating settings.

```
User Action → useMutation → supabase.from(table).insert/update/delete()
     ↓                              ↓
  Optimistic UI update        PostgREST → RLS check → database
     ↓                              ↓
  (rollback on error)         onSuccess → invalidateQueries → refetch
```

### Pattern 2: AI Message (Client → Edge Function → SSE) 

Used for sending messages that trigger AI responses.

```
User clicks Send
     ↓
  Insert user message (optimistic) → add to messages cache
     ↓
  fetch(cipher-route) → SSE stream begins
     ↓
  onToken → append to streamingContent state
     ↓
  onDone → invalidate ["messages", chatId] → refetch includes persona message
     ↓
  cipher-memory fires (async, no client awareness)
```

### Pattern 3: Realtime Subscription (Database → Supabase Realtime → Client) 

Used for receiving updates pushed by other sources (Edge Functions, other clients in future multi-user).

```
Edge Function inserts/updates row (via service client)
     ↓
  Supabase Realtime detects change (postgres_changes channel)
     ↓
  RealtimeProvider / useChatRealtime receives event
     ↓
  Handler updates TanStack Query cache directly OR invalidates query
     ↓
  Component re-renders with new data
```

### Pattern 4: Background Fetch (Component Mount → Query → Cache) 

Used for initial data loading on screen navigation.

```
User navigates to /spaces
     ↓
  SpacesScreen mounts → useInstances() hook fires
     ↓
  TanStack Query checks cache:
     ├── Fresh (< 30s since last fetch) → return cached data, no request
     ├── Stale (> 30s) → return cached data, background refetch
     └── Missing → show loading skeleton, fetch from PostgREST
     ↓
  Data arrives → component renders
```

---

## 20.11 TypeScript Interfaces 

Core domain types used across the frontend. Generated from Supabase's database types and extended with client-side additions.

```ts
// src/types/database.ts
// Auto-generated via: npx supabase gen types typescript --local > src/types/database.ts
// This file is regenerated on schema changes and should not be manually edited.

export type Database = {
  public: {
    Tables: {
      profiles: { Row: Profile; Insert: ProfileInsert; Update: ProfileUpdate };
      chats: { Row: Chat; Insert: ChatInsert; Update: ChatUpdate };
      messages: { Row: Message; Insert: MessageInsert; Update: MessageUpdate };
      personas: { Row: Persona; Insert: PersonaInsert; Update: PersonaUpdate };
      instances: { Row: Instance; Insert: InstanceInsert; Update: InstanceUpdate };
      // ... all 35 tables
    };
    Functions: {
      search_chat_titles: { Args: { p_user_id: string; p_query: string; p_limit: number }; Returns: SearchResult[] };
      // ... all RPC functions
    };
  };
};
```

```ts
// src/types/chat.ts
export interface Chat {
  id: string;
  user_id: string;
  title: string;
  auto_title: string | null;
  instance_id: string | null;
  pinned: boolean;
  archived: boolean;
  metadata: Record<string, any>;
  created_at: string;
  updated_at: string;
  deleted_at: string | null;
}

export interface ChatWithParticipants extends Chat {
  chat_participants: {
    persona_id: string;
    is_primary: boolean;
    personas: { name: string; avatar: string; color: string; status: string };
  }[];
}

export interface Message {
  id: string;
  chat_id: string;
  sender_type: "user" | "persona" | "system" | "cipher";
  sender_id: string | null;
  sender_name: string;
  content: string;
  content_blocks: any[];
  model_used: string | null;
  routing_reason: string | null;
  token_count: number | null;
  latency_ms: number | null;
  pinned: boolean;
  has_link: boolean;
  has_media: boolean;
  metadata: Record<string, any>;
  created_at: string;
  deleted_at: string | null;
}

export type ChatFilter = "all" | "pinned" | "archived" | "deleted";
```

```ts
// src/types/persona.ts
export interface Persona {
  id: string;
  user_id: string;
  name: string;
  role: string;
  purpose: string;
  personality: string;
  voice: string;
  avatar: string;
  color: string;
  status: "active" | "idle" | "archived";
  mood: string | null;
  settings: Record<string, any>;
  is_default: boolean;
  created_from_template: string | null;
  created_at: string;
  updated_at: string;
}

export interface PersonaSkill {
  id: string;
  persona_id: string;
  name: string;
  category: "coding" | "creative" | "research" | "writing" | "planning" | "reasoning" | "general";
  level: "beginner" | "intermediate" | "advanced" | "expert";
  description: string | null;
  temporary: boolean;
  expires_at: string | null;
  created_at: string;
}

export interface PersonaMemory {
  id: string;
  persona_id: string;
  type: "decision" | "fact" | "preference" | "skill" | "procedure";
  content: string;
  source_chat_id: string | null;
  source_message_id: string | null;
  confidence: "strong" | "medium" | "weak";
  tags: string[];
  active: boolean;
  layer: "open" | "closed";
  expires_at: string | null;
  created_at: string;
  updated_at: string;
}
```

```ts
// src/types/instance.ts
export interface Instance {
  id: string;
  user_id: string;
  name: string;
  description: string;
  type: string;
  icon: string;
  color: string;
  pinned: boolean;
  archived: boolean;
  settings: Record<string, any>;
  created_at: string;
  updated_at: string;
  deleted_at: string | null;
}

export interface Task {
  id: string;
  instance_id: string;
  user_id: string;
  title: string;
  description: string;
  status: "todo" | "in_progress" | "done" | "blocked";
  priority: "low" | "medium" | "high" | "urgent";
  assigned_persona_id: string | null;
  source_chat_id: string | null;
  due_date: string | null;
  sort_order: number;
  created_at: string;
  updated_at: string;
}
```

```ts
// src/types/stream.ts
export interface StreamTokenEvent {
  type: "token";
  content: string;
}

export interface StreamDoneEvent {
  type: "done";
  message_id: string;
  model_used: string;
  routing_reason: string;
  routing_note: string;
  token_count: number;
  latency_ms: number;
  prompt_tokens: number;
  completion_tokens: number;
  estimated_cost: number;
  fallback_used: boolean;
  fallback_note: string;
}

export interface StreamErrorEvent {
  type: "error";
  message: string;
  code: number;
  retryable: boolean;
  retry_after_seconds?: number;
}

export interface StreamStatusEvent {
  type: "status";
  status: "routing" | "generating" | "saving";
}

export type StreamEvent = StreamTokenEvent | StreamDoneEvent | StreamErrorEvent | StreamStatusEvent;
```

---

## 20.12 File Organization Summary 

```
src/
  app/                               — 14 route files
    layout.tsx
    page.tsx
    auth/{login,signup}/page.tsx
    auth/callback/route.ts
    chat/page.tsx
    search/page.tsx
    spaces/page.tsx, [id]/page.tsx
    files/page.tsx
    people/page.tsx, [id]/page.tsx
    teams/page.tsx
    browser/page.tsx
    insights/page.tsx
    settings/page.tsx

  components/                        — ~60 components
    providers/
      Providers.tsx                  — Provider stack wrapper
      AuthProvider.tsx               — Auth context + session management
      ThemeProvider.tsx               — Theme context + CSS class toggling
      RealtimeProvider.tsx            — Global Realtime subscriptions (Part 17)

    shell/                           — 5 shell components
      AppShell.tsx
      Sidebar.tsx
      TopBar.tsx
      CommandPalette.tsx
      MobileOverlay.tsx

    screens/                         — 14 screen components
      HomeScreen.tsx
      ChatScreen.tsx
      SearchScreen.tsx
      SpacesScreen.tsx
      InstanceDetailScreen.tsx
      FilesScreen.tsx
      PeopleScreen.tsx
      PersonaDetailScreen.tsx
      TeamsScreen.tsx
      BrowserScreen.tsx
      InsightsScreen.tsx
      SettingsScreen.tsx
      LoginScreen.tsx
      SignupScreen.tsx

    chat/                            — 8 chat components
      ChatDrawer.tsx
      MessageList.tsx
      Message.tsx
      MessageComposer.tsx
      PersonaSelector.tsx
      ModelSelector.tsx
      ChatNavPanel.tsx
      RightPanel.tsx

    browser/                         — 6 browser components
      BrowserViewport.tsx
      FloatingNavBar.tsx
      TabStrip.tsx
      ViewSwitcher.tsx
      FloatingPersonaBar.tsx
      BrowserChatPanel.tsx

    atoms/                           — 7 atom components
      Avatar.tsx
      StatusDot.tsx
      MemoryTypeIcon.tsx
      FilterChip.tsx
      TierBadge.tsx
      ConfidenceBadge.tsx
      ModelBadge.tsx

    shared/                          — 5 shared components
      EmptyState.tsx
      ConfirmDialog.tsx
      ContextMenu.tsx
      LoadingSkeleton.tsx
      NotificationDropdown.tsx

    modals/                          — 7 modal components
      ExportModal.tsx
      PricingModal.tsx
      OnboardingModal.tsx
      ShortcutsModal.tsx
      CreatePersonaModal.tsx
      CreateInstanceModal.tsx
      CreateTeamModal.tsx

  hooks/                             — 16 hooks
    use-profile.ts
    use-chats.ts
    use-messages.ts
    use-personas.ts
    use-instances.ts
    use-teams.ts
    use-model-registry.ts
    use-chat-operations.ts
    use-streaming-response.ts
    use-mobile.ts
    use-keyboard-shortcuts.ts
    use-tier-gates.ts
    use-chat-realtime.ts             (from Part 17)
    use-typing-indicator.ts          (from Part 17)
    use-instance-realtime.ts         (from Part 17)
    use-browser-realtime.ts          (from Part 17)

  stores/                            — 3 Zustand stores
    ui-store.ts
    navigation-store.ts
    browser-store.ts

  lib/                               — 8 library files
    supabase/
      client.ts                      — Browser Supabase client
      server.ts                      — Server Supabase client
      middleware.ts                   — Auth middleware
      types.ts                       — Generated database types
    utils/
      tier-gates.ts                  — Feature gating per tier (Part 2)
      formatting.ts                  — Date, number, text formatting
      constants.ts                   — App-wide constants (URLs, limits)
      theme.ts                       — Theme token objects (Part 1)

  types/                             — 6 type files
    database.ts                      — Supabase-generated types
    chat.ts
    persona.ts
    instance.ts
    stream.ts
    browser.ts

  styles/
    globals.css                      — Tailwind imports + CSS custom properties
```

**Total: \~118 files** (14 routes, \~60 components, 16 hooks, 3 stores, 8 lib files, 6 type files, 1 style file, plus providers and config)

---

## 20.13 Build & Development Tooling 

### Package Dependencies 

```json
{
  "dependencies": {
    "next": "^14.2",
    "react": "^18.3",
    "react-dom": "^18.3",
    "@supabase/supabase-js": "^2.45",
    "@supabase/ssr": "^0.4",
    "@tanstack/react-query": "^5.50",
    "zustand": "^4.5",
    "lucide-react": "^0.400",
    "tailwindcss": "^3.4",
    "tailwindcss-animate": "^1.0",
    "clsx": "^2.1",
    "date-fns": "^3.6",
    "react-markdown": "^9.0",
    "rehype-highlight": "^7.0",
    "remark-gfm": "^4.0"
  },
  "devDependencies": {
    "typescript": "^5.5",
    "@types/react": "^18.3",
    "@types/node": "^20.14",
    "eslint": "^8.57",
    "eslint-config-next": "^14.2",
    "prettier": "^3.3",
    "supabase": "^1.180"
  }
}
```

### Environment Variables (Client-Side) 

```
NEXT_PUBLIC_SUPABASE_URL=https://[project-ref].supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=[anon-key]
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
NEXT_PUBLIC_SITE_URL=https://aiconnected.app
```

### ESLint Configuration 

```json
{
  "extends": ["next/core-web-vitals", "next/typescript"],
  "rules": {
    "@typescript-eslint/no-explicit-any": "warn",
    "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
    "react-hooks/exhaustive-deps": "warn",
    "prefer-const": "error"
  }
}
```

---

## 20.14 Cross-Reference Summary 

| Frontend Concern | Source Part(s) |
| :---- | :---- |
| Design tokens, typography, spacing | Part 1 (Section 1.4) |
| Auth flow, tier gates, Stripe checkout | Part 2 |
| Shell layout, sidebar, top bar, command palette, keyboard shortcuts | Part 3 |
| Home dashboard stat cards, activity feed | Part 4 |
| Chat screen, message composer, message list, export modal | Part 5 |
| Search screen, unified search | Part 6 |
| Spaces screen, instance detail, tasks, workspace items | Part 7 |
| Files screen, file grid, upload, bulk actions | Part 8 |
| People screen, persona detail, skills, memory, health tabs | Part 9 |
| Teams screen, team detail, execution plan, run history | Part 10 |
| Browser screen, viewport, tabs, extract panel | Part 11 |
| Insights screen, analytics tabs, charts | Part 12 |
| Settings screen, 6 tabs, model role assignments | Part 13 |
| Cipher integration (streaming, routing notes) | Part 14 |
| CogniGraph (memory display, checkpoints) | Part 15 |
| Model selector, streaming hook, model badges | Part 16 |
| Realtime provider, typing indicator, live updates | Part 17 |
| Database types (auto-generated) | Part 18 |
| Edge Function invocations (fetch calls) | Part 19 |

---

*End of Part 20\. Proceed to Part 21: Empty States & Error Handling.*

# PART 21: EMPTY STATES, ERROR HANDLING & LOADING STATES 

---

This part specifies every empty state, error state, and loading state across the entire application. These are not afterthoughts — they are the first things many users see and the moments where trust is built or broken. Every screen, panel, and sub-section has a defined behavior for when there is no data, when something goes wrong, and when data is being fetched.

The design philosophy: empty states should be inviting and actionable (never dead ends), error states should be honest and recoverable (never vague), and loading states should be fast and structural (never jarring).

---

## 21.1 Shared Components 

### EmptyState Component 

All empty states use the same reusable component (Part 20, Section 20.9). This section defines the canonical implementation.

```ts
// src/components/shared/EmptyState.tsx

interface EmptyStateProps {
  icon: React.ComponentType<{ size?: number; strokeWidth?: number }>;
  title: string;
  description: string;
  action?: {
    label: string;
    onClick: () => void;
    variant?: "primary" | "bordered";    // Default: "primary"
  };
  secondaryAction?: {
    label: string;
    onClick: () => void;
  };
  size?: "sm" | "md" | "lg";            // Default: "md"
}
```

**Size variants:**

| Size | Icon | Title | Description | Padding | Max Width |
| :---- | :---- | :---- | :---- | :---- | :---- |
| `sm` | 20px | 13px, weight 400 | 11px, weight 300 | 24px 16px | 280px |
| `md` | 28px | 14px, weight 400 | 12px, weight 300 | 48px 20px | 360px |
| `lg` | 32px | 16px, weight 400 | 13px, weight 300 | 60px 20px | 420px |

**Layout:**

```ts
{
  display: "flex",
  flexDirection: "column",
  alignItems: "center",
  textAlign: "center",
  padding: sizePadding[size],
}
```

- Icon: `textFaint` color, strokeWidth 1.5, marginBottom 12px  
- Title: `text` color, marginBottom 0  
- Description: `textMuted` color, marginTop 6–8px, lineHeight 1.5, maxWidth per size  
- Primary action button: accent bg, accentText, borderRadius 8, padding `8px 20px`, fontSize 12, fontWeight 450, marginTop 20px  
- Bordered action button: transparent bg, `border` color 1px solid, `text` color, same dimensions  
- Secondary action: text link, 12px, weight 400, `accent` color, marginTop 12px, cursor pointer

### ErrorBanner Component 

A dismissible banner that appears at the top of the content area for recoverable errors.

```ts
// src/components/shared/ErrorBanner.tsx

interface ErrorBannerProps {
  message: string;
  type?: "error" | "warning" | "info";
  action?: {
    label: string;
    onClick: () => void;
  };
  dismissible?: boolean;
  onDismiss?: () => void;
}
```

**Layout:**

```ts
// Error variant
{
  display: "flex",
  alignItems: "center",
  gap: 8,
  padding: "10px 14px",
  background: "rgba(239, 68, 68, 0.08)",   // Red tint
  border: "1px solid rgba(239, 68, 68, 0.15)",
  borderRadius: 10,
  marginBottom: 16,
  fontSize: 12,
  fontWeight: 350,
  color: theme.text,
}

// Warning variant
{
  background: "rgba(245, 158, 11, 0.08)",   // Amber tint
  border: "1px solid rgba(245, 158, 11, 0.15)",
}

// Info variant
{
  background: theme.surfaceAlt,
  border: `1px solid ${theme.borderSubtle}`,
}
```

- Left icon: AlertCircle (error), AlertTriangle (warning), Info (info) — 14px, matching color  
- Message text: flex 1  
- Action button: 11px, weight 450, underline, cursor pointer  
- Dismiss: X icon (12px, `textFaint`, cursor pointer)

### LoadingSkeleton Component 

Pulsing placeholder shapes that match the content they replace.

```ts
// src/components/shared/LoadingSkeleton.tsx

interface SkeletonProps {
  variant: "card" | "list-item" | "text-line" | "avatar" | "stat-card" | "grid-card";
  count?: number;          // Default: 1
  className?: string;
}
```

**Animation:**

```css
@keyframes skeleton-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

.skeleton-bar {
  background: var(--surface-alt);
  border-radius: 4px;
  animation: skeleton-pulse 1.5s ease-in-out infinite;
}
```

**Variant dimensions:**

| Variant | Structure |
| :---- | :---- |
| `card` | 100% width, 80px height, borderRadius 12, surfaceAlt bg |
| `list-item` | Row: 14×14 square \+ 200×14 bar \+ 140×10 bar (right-aligned) |
| `text-line` | 100% width (or 75% for last), 12px height, borderRadius 4 |
| `avatar` | Circle, 32px diameter |
| `stat-card` | 160×100 rectangle, borderRadius 12, contains 80×28 bar \+ 120×10 bar |
| `grid-card` | 100% width, 160px height, borderRadius 12, contains stacked bars |

### InlineRetry Component 

A compact retry prompt that replaces failed content inline.

```ts
// src/components/shared/InlineRetry.tsx

interface InlineRetryProps {
  message: string;
  onRetry: () => void;
  isRetrying?: boolean;
}
```

**Layout:**

```ts
{
  display: "flex",
  alignItems: "center",
  gap: 6,
  fontSize: 11,
  color: theme.textMuted,
  fontWeight: 350,
}
```

- AlertCircle icon (11px, `textFaint`)  
- Message text  
- "Retry" link (accent color, cursor pointer, weight 400\) or spinner (12px, animate-spin) if retrying

---

## 21.2 Empty States by Screen 

### Home Dashboard 

**First-time user (zero instances \+ zero personas \+ zero chats):**

Replaces the entire stats grid and activity feed with 3 getting-started cards (Part 4, Section 4.6):

| Card | Icon | Title | Description | Button | Action |
| :---- | :---- | :---- | :---- | :---- | :---- |
| 1 | LayoutGrid (24px) | "Create your first Instance" | "Instances are workspaces for projects, clients, or topics. They keep your conversations organized." | "Create Instance" | Navigate to `/spaces?create=true` |
| 2 | Users (24px) | "Meet your first Persona" | "Personas are AI collaborators with real skills and memory. They get better at helping you over time." | "Create Persona" | Open CreatePersonaModal |
| 3 | MessageSquare (24px) | "Start a conversation" | "Chat is the backbone of everything. Start talking and your workspace will grow around you." | "New Chat" | Navigate to `/chat?new=true` |

Below cards: "Replay the welcome tour" link (12px, weight 400, `accent`, centered). Opens onboarding overlay.

Cards use: surface bg, 1px border, borderRadius 12, padding 24, gap 12\. Grid: 3 columns on desktop, 1 column on mobile.

**Partial empty (one stat is zero, rest are non-zero):**

Stat card shows "0" (not "—"). A hint line appears below the stats grid:

| Condition | Hint |
| :---- | :---- |
| instances \=== 0 | "Create an Instance to organize your conversations." |
| personas \=== 0 | "Create a Persona to unlock AI collaboration." |
| chats \=== 0 | "Start a conversation to begin working with your Personas." |
| tasks \=== 0 | (No hint — tasks are optional) |

Hint: 12px, weight 300, `textFaint`. Multiple zeros → no hint (full empty state handles it).

**Activity feed empty (user has data but no recent activity):**

- Activity icon (20px, `textFaint`, marginBottom 8px)  
- "No recent activity" (12px, weight 400, `textMuted`)  
- "Activity will appear as you chat, create, and organize." (11px, weight 300, `textFaint`, marginTop 4px)

---

### Chat Screen 

**No chats exist (drawer):**

```
<EmptyState
  icon={MessageSquare}
  title="Start your first conversation"
  description="Chat with AI Personas that remember context and learn over time."
  action={{ label: "New Chat", onClick: createNewChat }}
  size="md"
/>
```

**No chat selected (conversation area):**

The conversation area shows a centered prompt:

- Sparkles icon (28px, `textFaint`, marginBottom 12px)  
- "Select a conversation or start a new one" (14px, weight 400, `text`)  
- "New Chat" button (accent bg, marginTop 16px)

This state appears when the user navigates to `/chat` without an active chat ID.

**No messages in a chat (new conversation):**

- The conversation header shows the chat title and participant chips normally  
- The message area is empty except for a centered welcome prompt:  
  - Persona avatar (40px) of the primary participant  
  - "{persona\_name} is ready to help." (14px, weight 350, `textSec`)  
  - "What would you like to work on?" (12px, weight 300, `textMuted`, marginTop 4px)  
  - 3–4 suggested prompts as clickable chips below (surfaceAlt bg, 11px, borderRadius 16, padding `6px 14px`, cursor pointer):  
    - "Help me brainstorm ideas"  
    - "Review my document"  
    - "Explain a concept"  
    - "Help me write something"  
  - Clicking a prompt chip inserts it into the composer

**No messages match filter:**

- "No messages match this filter." (12px, weight 300, `textFaint`, textAlign center, padding 48px)

**No pinned messages (right panel → Memories filter):**

- "No pinned messages yet. Hover and click the pin icon." (11px, weight 300, `textFaint`, centered, padding `36px 16px`)

**Chat nav panel empty (no checkpoints yet):**

- "Conversation map will appear as the chat grows." (11px, weight 300, `textFaint`, padding `20px 12px`)

**Right panel — Participants tab empty (no participants):**

- "No Personas assigned." (12px, weight 350, `textMuted`)  
- "Add Persona" link (accent color, 11px)

**Right panel — Memories tab empty:**

- "No memories extracted yet." (12px, weight 350, `textMuted`)  
- "Memories appear as Personas learn from your conversations." (11px, weight 300, `textFaint`, marginTop 4px)

**Right panel — Files tab empty:**

- "No files in this conversation." (12px, weight 350, `textMuted`)

---

### Search Screen 

**No query entered (initial state):**

```
<EmptyState
  icon={Search}
  title="Search across your entire workspace"
  description="Find conversations, messages, files, personas, instances, and memories."
  size="md"
/>
```

Below, show recent search history (if any): "Recent searches" header \+ clickable history items. If no history: nothing additional.

**No results for query:**

- Search icon (28px, `textFaint`, marginBottom 12px)  
- "No results for "{query}"" (14px, weight 400, `text`; query in weight 300\)  
- Filter-specific suggestion (12px, weight 300, `textMuted`, marginTop 8px):

| Active Filter | Suggestion |
| :---- | :---- |
| All | "Try different keywords or check your spelling." |
| Chats | "No matching conversations. Try searching within a specific Instance." |
| Messages | "No matching messages. Try broader keywords." |
| Files | "No matching files. Check the file name or tags." |
| Personas | "No matching personas." |
| Instances | "No matching instances." |
| Memories | "No matching memories. Memories are created from your conversations over time." |

---

### Spaces (Instance List) 

**No instances:**

```
<EmptyState
  icon={LayoutGrid}
  title="No spaces yet"
  description="Create your first space to organize your conversations and files."
  action={{ label: "Create Space", onClick: openCreateModal }}
  size="lg"
/>
```

**No instances match filter (if filtering is active):**

- "No spaces match this filter." (13px, weight 300, `textFaint`, centered, padding 48px)

---

### Instance Detail 

**Chats tab — no chats in this instance:**

```
<EmptyState
  icon={MessageSquare}
  title="No conversations yet"
  description="Start a chat in this instance or move existing conversations here."
  action={{ label: "New Chat", onClick: () => createChatInInstance(instanceId) }}
  size="md"
/>
```

**Tasks tab — no tasks:**

```
<EmptyState
  icon={Hash}
  title="No tasks yet"
  description="Tasks can be created manually or extracted from conversations by your Personas."
  action={{ label: "Add Task", onClick: openTaskCreate }}
  size="md"
/>
```

**Files tab — no files:**

```
<EmptyState
  icon={FileText}
  title="No files yet"
  description="Upload files or they'll appear here when AI generates them."
  action={{ label: "Upload", onClick: openFileUpload }}
  size="md"
/>
```

**Personas tab — no assigned personas:**

```
<EmptyState
  icon={Users}
  title="No Personas assigned"
  description="Add Personas to this instance so they can access its context during conversations."
  action={{ label: "Add Persona", onClick: openPersonaSelector }}
  size="md"
/>
```

---

### Files Screen 

**No files at all:**

```
<EmptyState
  icon={Upload}
  title="No files yet"
  description="Upload files or let your Personas generate them during conversations."
  action={{ label: "Upload", onClick: openFilePicker }}
  size="lg"
/>
```

**No files match filter \+ search:**

- "No files match this filter." (13px, weight 300, `textFaint`, centered, padding 48px)

---

### People (Persona List) 

**No personas:**

```
<EmptyState
  icon={Users}
  title="Create your first Persona"
  description="Personas are AI collaborators with real skills and memory. Start with a template or build from scratch."
  action={{ label: "Create Persona", onClick: openCreatePersonaModal }}
  secondaryAction={{ label: "Browse templates", onClick: scrollToTemplates }}
  size="lg"
/>
```

Below the empty state: a horizontal carousel of 4–5 persona template cards (from `persona_templates` where `is_builtin = true`). Each card: 200px wide, avatar color block \+ name \+ role \+ "Use Template" link.

---

### Persona Detail 

**Skills tab — no skills:**

```
<EmptyState
  icon={Zap}
  title="No skills defined"
  description="Skills help Cipher route messages to the right Persona. Add skills that match this Persona's expertise."
  action={{ label: "Add Skill", onClick: openAddSkill }}
  size="sm"
/>
```

**Memory tab — no memories:**

```
<EmptyState
  icon={Brain}
  title="No memories yet"
  description="Memories are automatically extracted from conversations. This Persona hasn't learned anything yet."
  size="sm"
/>
```

**Memory tab — no memories match filter:**

- "No memories match this filter." (12px, weight 300, `textFaint`, padding 24px, centered)

**Health tab — no snapshots:**

```
<EmptyState
  icon={Activity}
  title="No health data yet"
  description="Health snapshots are generated daily. Check back tomorrow."
  size="sm"
/>
```

**Boundaries section — no boundaries:**

- "No boundaries set. Boundaries define what this Persona will and won't do." (12px, weight 300, `textMuted`, marginBottom 12px)  
- "Add Boundary" link (11px, accent color)

---

### Teams Screen 

**No teams:**

```
<EmptyState
  icon={Users}
  title="Create your first team"
  description="Teams coordinate multiple Personas to work together on complex goals autonomously."
  action={{ label: "New Team", onClick: openCreateTeamModal }}
  size="lg"
/>
```

**Team detail — no execution plan steps:**

- "No steps defined. Add steps to define your team's workflow." (13px, weight 300, `textFaint`, fontStyle italic)

**Team detail — no run history:**

- "No runs yet. Click 'Run Now' to execute your team's plan." (13px, weight 300, `textFaint`, padding `24px 0`, textAlign center)

**Team detail — activity tab empty:**

- "No activity yet. Run your team to see execution logs here." (13px, weight 300, `textFaint`, padding `20px 0`)

---

### Browser Screen 

**No active sessions (landing state):**

```
<EmptyState
  icon={Compass}
  title="Browse the web with your Personas"
  description="Open a browser session to research, extract content, and chat with your Personas about what you find."
  action={{ label: "Open Browser", onClick: createNewSession }}
  size="lg"
/>
```

**History panel — no history:**

- Clock icon (28px, `textFaint`)  
- "No browsing history yet" (14px, weight 400\)  
- "Pages you visit with Personas will appear here." (12px, weight 300, `textMuted`)

**Extracts panel — no extracts:**

- Download icon (28px, `textFaint`)  
- "No saved extracts" (14px, weight 400\)  
- "Extract content from web pages to save it to your Instances." (12px, weight 300, `textMuted`)

---

### Insights Screen 

**No data (new user):**

```
<EmptyState
  icon={BarChart3}
  title="No data yet"
  description="Start chatting with your Personas to see analytics here."
  action={{ label: "Go to Chat", onClick: () => navigate("/chat") }}
  size="lg"
/>
```

**Persona Performance tab — no personas:**

- "No personas yet" (14px, weight 400, `text`)  
- "Create a Persona to start tracking performance." (12px, weight 300, `textMuted`)

**Model Usage tab — no usage:**

- "No model usage data yet" (14px, weight 400, `text`)  
- "Send messages to start tracking model usage." (12px, weight 300, `textMuted`)

**Memory Health tab — no memories:**

- "No memories yet" (14px, weight 400, `text`)  
- "Memories are created as you chat with Personas over time." (12px, weight 300, `textMuted`)

**Individual stat card with no data:**

Value shows "—" (em-dash, 26px, weight 200, `textFaint`). Trend shows "No data for this period" (10px, `textFaint`). Card remains fully rendered and clickable.

---

### Settings Screen 

**API Keys tab — no keys:**

- Key icon (28px, `textFaint`, marginBottom 12px)  
- "No API keys connected" (14px, weight 400, `text`)  
- "Connect your own API keys to unlock unlimited chats and access additional models." (12px, weight 300, `textMuted`, marginTop 6px, maxWidth 360px, lineHeight 1.5)  
- "Add API Key" button (accent bg, marginTop 16px)

**Privacy tab — no instruction memory rules:**

- BookOpen icon (28px, `textFaint`, marginBottom 12px)  
- "No learned rules yet" (14px, weight 400, `text`)  
- "As you interact with your Personas, Cipher will learn behavioral rules from your patterns and preferences." (12px, weight 300, `textMuted`, marginTop 6px, maxWidth 380px, lineHeight 1.5, margin auto)  
- "Add Rule Manually" button (bordered, 12px, marginTop 16px)

**Privacy tab — no flagged memory conflicts:**

- "No conflicts detected." (12px, weight 350, `textMuted`)

**Types tab — no custom types (Premium+):**

- Layers icon (28px, `textFaint`, marginBottom 12px)  
- "No custom types yet" (14px, weight 400, `text`)  
- "Instance Types provide default settings that new Instances inherit." (12px, weight 300, `textMuted`)  
- "Create Type" button (accent bg, marginTop 16px)

---

### Command Palette 

**No matching results:**

- "No results" (13px, weight 350, `textFaint`, padding `20px 0`, centered)

### Notification Dropdown 

**No notifications:**

- Bell icon (20px, `textFaint`, marginBottom 8px)  
- "All caught up" (13px, weight 400, `text`)  
- "Notifications will appear here." (11px, weight 300, `textMuted`, marginTop 4px)

---

## 21.3 Error States 

### Error Classification 

All errors in the application fall into one of these categories, each with a defined UI response:

| Category | HTTP Code(s) | UI Treatment | User Action |
| :---- | :---- | :---- | :---- |
| Network failure | — (fetch failed) | ErrorBanner with retry | Click "Retry" |
| Authentication expired | 401 | Redirect to login | Re-authenticate |
| Forbidden (tier gate) | 403 | Upgrade prompt | Click "Upgrade" |
| Not found | 404 | Toast \+ navigate back | None (auto) |
| Validation error | 400 | Inline field errors | Fix input |
| Rate limit | 429 | Toast with countdown | Wait |
| AI model error | 500, 502, 503 | Inline error in chat \+ retry | Click "Retry" |
| Partial failure | Mixed | Warning banner \+ partial results | Optional retry |
| Storage full | 413 / custom | Toast with upgrade | Manage files or upgrade |

---

### Network Error 

**Detection:** Any `fetch` or Supabase SDK call that throws a `TypeError` (network) or receives no response.

**UI — Global banner:**

```ts
<ErrorBanner
  type="error"
  message="Unable to connect. Check your internet connection."
  action={{ label: "Retry", onClick: () => queryClient.refetchQueries() }}
  dismissible={true}
/>
```

Positioned: fixed at the top of the content area (below top bar), z-index 50, full width with 16px horizontal margin.

**Behavior:**

- Appears after 3 consecutive failed requests (not on first transient failure)  
- Dismisses automatically when a request succeeds  
- "Retry" refetches all stale queries  
- On mobile: same position, 12px horizontal margin

---

### Authentication Session Expired 

**Detection:** Any API call returns 401, or `onAuthStateChange` fires with `SIGNED_OUT` event.

**UI:**

1. Immediately redirect to `/auth/login?returnUrl={currentPath}`  
2. After redirect, show toast: "Your session expired. Please sign in again."  
3. After re-authentication, redirect back to `returnUrl`

**Behavior:**

- The AuthProvider (Part 20, Section 20.2) handles this globally  
- No error banner or modal — immediate redirect  
- In-progress work (unsent message text) is not preserved (the composer state is ephemeral)

---

### Tier-Gated Feature (403) 

**Detection:** User attempts an action restricted by their tier.

**UI — Inline gate:**

```ts
// Used inside tabs, sections, and buttons that require a higher tier
<div style={{
  padding: "40px 20px",
  textAlign: "center",
  background: theme.surfaceAlt,
  borderRadius: 12,
  border: `1px dashed ${theme.border}`,
}}>
  <Lock size={20} color={theme.textFaint} style={{ marginBottom: 8 }} />
  <div style={{ fontSize: 14, fontWeight: 400, color: theme.text }}>
    {gateTitle}
  </div>
  <div style={{ fontSize: 12, fontWeight: 300, color: theme.textMuted, marginTop: 6, maxWidth: 320, margin: "6px auto 0" }}>
    {gateDescription}
  </div>
  <button onClick={openPricingModal} style={{
    background: theme.accent, color: theme.accentText,
    borderRadius: 8, padding: "8px 20px", fontSize: 12, fontWeight: 450,
    marginTop: 16, border: "none", cursor: "pointer",
  }}>
    Upgrade to {requiredTier}
  </button>
</div>
```

**Gate content per feature:**

| Feature | Title | Description | Required Tier |
| :---- | :---- | :---- | :---- |
| Teams | "Unlock Teams" | "Coordinate multiple Personas on complex workflows." | Premium |
| Browser | "Unlock Browser" | "Browse the web with AI Personas alongside you." | Premium |
| Instance Types | "Unlock Instance Types" | "Create reusable configuration templates for your workspaces." | Premium |
| Encrypted export | "Unlock Encrypted Export" | "Export conversations with AES-256 encryption." | Pro |
| Multi-model routing | "Unlock Multi-Model Routing" | "Assign specific AI models to different roles." | Plus |
| Advanced analytics | "Unlock Advanced Analytics" | "Access model usage, memory health, and extended time ranges." | Plus |
| File uploads | "Unlock File Uploads" | "Upload documents to your conversations and instances." | Plus |

---

### Rate Limit Hit (429) 

**Detection:** `cipher-route` or any Edge Function returns 429\. SSE error event with `code: 429`.

**UI — Toast notification:**

```ts
showToast(
  `Message limit reached. Resets in ${formatTimeRemaining(retryAfterSeconds)}.`,
  "warning",
  0  // Persistent until dismissed
);
```

**Additional in chat:**

If rate limit is hit during message send, the streaming area shows:

```ts
<div style={{
  padding: "12px 16px",
  background: "rgba(245, 158, 11, 0.08)",
  border: "1px solid rgba(245, 158, 11, 0.15)",
  borderRadius: 10,
  fontSize: 12,
  fontWeight: 350,
  color: theme.text,
  display: "flex",
  alignItems: "center",
  gap: 8,
}}>
  <Clock size={14} color="#f59e0b" />
  <span>Daily message limit reached. Resets in {timeRemaining}.</span>
  <button onClick={openPricingModal} style={{
    background: "none", border: "none", color: theme.accent,
    fontSize: 11, fontWeight: 450, cursor: "pointer", textDecoration: "underline",
  }}>
    Upgrade for more
  </button>
</div>
```

**Behavior:**

- BYOK users are exempt from rate limits  
- Countdown updates every second via `setInterval`  
- When countdown reaches 0, the message auto-clears and the composer re-enables  
- "Upgrade for more" opens the PricingModal

---

### AI Generation Failure 

**Detection:** SSE error event from `cipher-route`, or stream connection drops without a `done` event.

**Error types and UI responses:**

| Error | User Message | UI Treatment |
| :---- | :---- | :---- |
| Model API 429 | "The AI model is temporarily busy. Retrying..." | Auto-retry with 2s delay (invisible to user) |
| Model API 500/502/503 | "The AI service encountered an error. Please try again." | Inline error \+ "Retry" button |
| Context too long | "Conversation too long. Older context was trimmed." | Auto-retry with truncated context (toast notification) |
| Invalid BYOK key (401/403) | "Your API key was rejected. Please check Settings." | Inline error \+ "Go to Settings" link |
| Network error (fetch failed) | "Connection lost. Please check your internet and try again." | Inline error \+ "Retry" button |
| Stream aborted (user stop) | (No error shown) | Partial response saved with `metadata.stopped = true` |
| All models failed | "All AI models are currently unavailable. Please try again later." | Inline error, no retry |

**Inline error display (in message list):**

When an AI response fails, an error message appears in place of the expected persona message:

```ts
<div style={{
  padding: "12px 16px",
  background: "rgba(239, 68, 68, 0.06)",
  border: "1px solid rgba(239, 68, 68, 0.12)",
  borderRadius: 10,
  display: "flex",
  alignItems: "center",
  gap: 10,
  marginTop: 8,
}}>
  <AlertCircle size={16} color="#ef4444" />
  <div style={{ flex: 1 }}>
    <div style={{ fontSize: 12, fontWeight: 400, color: theme.text }}>
      {userMessage}
    </div>
    {retryable && (
      <button onClick={retrySend} style={{
        background: "none", border: "none", color: theme.accent,
        fontSize: 11, fontWeight: 450, cursor: "pointer",
        padding: 0, marginTop: 4, textDecoration: "underline",
      }}>
        Try again
      </button>
    )}
  </div>
</div>
```

**Retry behavior:**

1. "Try again" re-invokes `cipher-route` with the same user message  
2. If the primary model failed and a fallback is configured, the retry uses the fallback model automatically (Part 14, Section 14.7)  
3. Maximum 2 visible retries — after that, the error becomes persistent with "Please try again later"  
4. The original user message remains in the chat; only the error block is replaced on retry

---

### File Upload Failure 

**Detection:** Supabase Storage upload returns an error.

**UI — Inline in file list:**

The uploading file card/row shows the progress bar in red (`#ef4444` fill) and replaces the metadata with:

```ts
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
  <AlertCircle size={11} color="#ef4444" />
  <span style={{ fontSize: 11, fontWeight: 350, color: "#ef4444" }}>Upload failed</span>
  <button onClick={retryUpload} style={{
    background: "none", border: "none", color: theme.accent,
    fontSize: 11, fontWeight: 400, cursor: "pointer",
  }}>
    Retry
  </button>
  <button onClick={dismissUpload} style={{
    background: "none", border: "none", color: theme.textFaint,
    fontSize: 11, fontWeight: 400, cursor: "pointer",
  }}>
    Dismiss
  </button>
</div>
```

**Common failure reasons:**

| Reason | Detection | User Message |
| :---- | :---- | :---- |
| File too large | `size_bytes > tier_limit` (client-side check) | "File exceeds the {limit}MB limit for your plan." |
| Invalid file type | MIME type not in allowed list | "This file type is not supported." |
| Storage quota exceeded | Storage check before upload | "Storage is full. Delete files or upgrade your plan." |
| Network error | Upload fetch fails | "Upload failed. Check your connection and try again." |
| Server error | 500 from storage API | "Upload failed. Please try again." |

**Behavior:**

- File size and type are validated client-side before upload begins (instant feedback)  
- Storage quota is checked via `get_storage_usage()` RPC before upload  
- On retry, the same file is re-uploaded from the browser's file reference  
- "Dismiss" removes the failed upload entry from the `uploadingFiles` state

---

### Browser Proxy Failure 

**Detection:** The iframe fails to load, or `browser-proxy` Edge Function returns an error.

**UI — In viewport area:**

```ts
<div style={{
  display: "flex",
  flexDirection: "column",
  alignItems: "center",
  justifyContent: "center",
  height: "100%",
  padding: 40,
  textAlign: "center",
}}>
  <Globe size={32} color={theme.textFaint} style={{ marginBottom: 14 }} />
  <div style={{ fontSize: 15, fontWeight: 400, color: theme.text }}>
    This page couldn't be loaded
  </div>
  <div style={{ fontSize: 12, fontWeight: 300, color: theme.textMuted, marginTop: 8, maxWidth: 360 }}>
    The page may be blocking proxy access, require authentication, or be temporarily unavailable.
  </div>
  <div style={{ display: "flex", gap: 10, marginTop: 20 }}>
    <button onClick={retryLoad}>Try again</button>
    <button onClick={copyUrl} variant="bordered">Copy URL</button>
  </div>
</div>
```

**Specific error messages:**

| Proxy Error | Message |
| :---- | :---- |
| Target returns 403 | "This site blocks automated access." |
| Target returns 404 | "Page not found." |
| Target timeout (\>30s) | "The page took too long to load." |
| Invalid URL | "That doesn't look like a valid URL." |
| Blocked URL (localhost, etc.) | "This URL cannot be accessed." |

---

### Data Query Failure (Generic) 

**Detection:** Any `useQuery` or `useMutation` that returns an error.

**UI — Per-component error boundary:**

For non-critical data (analytics, activity feed, browser history):

```ts
if (error) {
  return (
    <InlineRetry
      message="Couldn't load data."
      onRetry={() => refetch()}
      isRetrying={isFetching}
    />
  );
}
```

For critical data (chat list, message list, file list):

```ts
if (error) {
  return (
    <ErrorBanner
      type="error"
      message="Failed to load. Please try again."
      action={{ label: "Retry", onClick: () => refetch() }}
    />
  );
}
```

**TanStack Query retry policy:**

```ts
// Global default (set in QueryClient)
retry: 1,                    // 1 automatic retry before showing error
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
```

---

### Stripe/Payment Failure 

**Detection:** `checkout.session.completed` never arrives, or `invoice.payment_failed` webhook fires.

**UI — Payment failed notification:**

A notification is created (via `stripe-webhook` Edge Function) and displayed in the notification dropdown:

- AlertCircle icon (red tint)  
- Title: "Payment failed"  
- Body: "Your subscription payment could not be processed. Please update your payment method."  
- action\_url: `/settings?tab=general` (scrolls to billing section)

**UI — Past due banner:**

If `subscriptions.status === "past_due"`, a persistent (non-dismissible) warning banner appears at the top of every screen:

```ts
<ErrorBanner
  type="warning"
  message="Your payment is past due. Update your payment method to keep your plan."
  action={{ label: "Update Payment", onClick: () => navigate("/settings?tab=general") }}
  dismissible={false}
/>
```

---

### Export Failure 

**Detection:** `chat-export` Edge Function returns an error.

**UI — In ExportModal:**

```ts
<div style={{
  padding: "12px 16px",
  background: "rgba(239, 68, 68, 0.06)",
  border: "1px solid rgba(239, 68, 68, 0.12)",
  borderRadius: 8,
  display: "flex",
  alignItems: "center",
  gap: 8,
  marginTop: 12,
}}>
  <AlertCircle size={14} color="#ef4444" />
  <span style={{ fontSize: 12, fontWeight: 350, color: theme.text }}>
    Export failed. Please try again.
  </span>
</div>
```

The "Export" button re-enables so the user can retry. The modal stays open.

---

### Team Execution Failure 

**Detection:** `team-execute` Edge Function marks a `team_tasks` row as "failed".

**UI — In team run history:**

Failed runs show a red status badge ("Failed") and expand to reveal:

- AlertTriangle icon (10px, `#ef4444`)  
- Truncated error message (11px, weight 300, `#ef4444`, maxWidth 300px, overflow ellipsis)  
- The specific step that failed is highlighted with a red left border

**Failed steps show:**

- Status: "Failed" badge (9px, red bg, white text)  
- Error: the `error_message` from `team_runs` (12px, weight 300, `textMuted`)  
- Skipped downstream steps show "Skipped" badge with note: "Skipped due to upstream failure"

---

### API Key Validation Failure 

**Detection:** `validate-api-key` Edge Function returns `{ valid: false }`.

**UI — In Settings → API Keys:**

The key row updates to show:

- Status badge: "Invalid" (9px, red bg, white text, borderRadius 10\)  
- Error message below: "This key was rejected by {provider}. Please check and try again." (11px, weight 300, `textMuted`)  
- "Remove" button remains available  
- "Re-validate" button (bordered, 11px) allows the user to try again after fixing the key in the provider's dashboard

---

## 21.4 Loading States 

### Page-Level Loading 

**Initial app load (auth check):**

Full-screen centered spinner:

```ts
<div style={{
  width: "100vw",
  height: "100vh",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  background: theme.bg,
}}>
  <Sparkles size={24} className="animate-pulse" color={theme.textFaint} />
</div>
```

This shows while `AuthProvider.isLoading === true` (typically 100–300ms).

**Screen navigation:**

No full-page loading state. Each screen component handles its own loading via TanStack Query's `isLoading` flag. Content loads in sections — skeleton → data. The shell (sidebar, top bar) remains fully interactive.

---

### Skeleton Screens 

Every list, grid, and data-heavy section has a skeleton variant that matches its final layout dimensions.

**Chat list (drawer) skeleton:**

```ts
// 6 skeleton chat items
<div style={{ padding: "9px 14px", display: "flex", gap: 10 }}>
  <div className="skeleton-bar" style={{ width: 32, height: 32, borderRadius: "50%" }} />
  <div style={{ flex: 1 }}>
    <div className="skeleton-bar" style={{ width: 140, height: 12 }} />
    <div className="skeleton-bar" style={{ width: 200, height: 10, marginTop: 6 }} />
  </div>
  <div className="skeleton-bar" style={{ width: 30, height: 10 }} />
</div>
```

**Message list skeleton (initial chat load):**

```ts
// 3 alternating message skeletons (user + persona + user)
// User message (right-aligned)
<div style={{ display: "flex", justifyContent: "flex-end", padding: "8px 16px" }}>
  <div className="skeleton-bar" style={{ width: "60%", height: 48, borderRadius: 12 }} />
</div>
// Persona message (left-aligned, with avatar)
<div style={{ display: "flex", gap: 8, padding: "8px 16px" }}>
  <div className="skeleton-bar" style={{ width: 28, height: 28, borderRadius: "50%" }} />
  <div className="skeleton-bar" style={{ width: "70%", height: 64, borderRadius: 12 }} />
</div>
```

**Instance grid skeleton:**

```ts
// 4 skeleton cards in 2×2 grid (desktop) or 1-column (mobile)
<div className="skeleton-bar" style={{ width: "100%", height: 140, borderRadius: 12 }} />
```

Each card contains: 24×24 icon square \+ 120×14 title bar \+ 200×10 description bar \+ 160×10 metadata bar.

**Persona grid skeleton:**

```ts
// 4 skeleton cards
<div className="skeleton-bar" style={{
  width: "100%", height: 120, borderRadius: 12,
  display: "flex", flexDirection: "column", alignItems: "center",
  padding: 20, gap: 8,
}}>
  <div className="skeleton-bar" style={{ width: 40, height: 40, borderRadius: "50%" }} />
  <div className="skeleton-bar" style={{ width: 100, height: 14 }} />
  <div className="skeleton-bar" style={{ width: 80, height: 10 }} />
</div>
```

**File list skeleton:**

```ts
// 5 skeleton rows
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 0", borderBottom: `1px solid ${theme.borderSubtle}` }}>
  <div className="skeleton-bar" style={{ width: 14, height: 14, borderRadius: 3 }} />
  <div className="skeleton-bar" style={{ width: 200, height: 14 }} />
  <div style={{ flex: 1 }} />
  <div className="skeleton-bar" style={{ width: 60, height: 10 }} />
  <div className="skeleton-bar" style={{ width: 80, height: 10 }} />
</div>
```

**Team list skeleton:**

```ts
// 3 skeleton cards
<div className="skeleton-bar" style={{
  padding: "16px 20px", borderRadius: 12,
  border: `1px solid ${theme.borderSubtle}`,
}}>
  <div className="skeleton-bar" style={{ width: 160, height: 14 }} />
  <div className="skeleton-bar" style={{ width: 260, height: 10, marginTop: 8 }} />
  <div className="skeleton-bar" style={{ width: 200, height: 10, marginTop: 16 }} />
</div>
```

**Search results skeleton:**

```ts
// 4 skeleton result cards
<div style={{ padding: "14px 0", borderBottom: `1px solid ${theme.borderSubtle}` }}>
  <div className="skeleton-bar" style={{ width: 260, height: 16 }} />
  <div className="skeleton-bar" style={{ width: 160, height: 10, marginTop: 6 }} />
  <div className="skeleton-bar" style={{ width: "100%", height: 12, marginTop: 10 }} />
  <div className="skeleton-bar" style={{ width: "75%", height: 12, marginTop: 6 }} />
</div>
```

**Dashboard stat cards skeleton:**

The 4 stat card positions show:

```ts
<div className="skeleton-bar" style={{ width: 160, height: 100, borderRadius: 12 }}>
  <div className="skeleton-bar" style={{ width: 80, height: 28, margin: "16px 14px 0" }} />
  <div className="skeleton-bar" style={{ width: 120, height: 10, margin: "12px 14px 0" }} />
</div>
```

Alternative for dashboard stats: show "—" in each value position (no skeleton animation). This is the approach specified in Part 4: "No skeleton animation. The '—' is sufficient visual feedback for 4 small stat cells."

**Analytics charts skeleton:**

```ts
// Chart placeholder
<div className="skeleton-bar" style={{
  width: "100%", height: 240, borderRadius: 12,
  display: "flex", alignItems: "flex-end", justifyContent: "space-around",
  padding: "0 20px 16px",
}}>
  {/* Fake bar chart bars at different heights */}
  {[40, 65, 80, 55, 90, 70, 45].map((h, i) => (
    <div key={i} className="skeleton-bar" style={{ width: 24, height: `${h}%`, borderRadius: 4 }} />
  ))}
</div>
```

**Settings tabs skeleton:**

```ts
// Form field skeletons (5 fields)
{[160, 200, 140, 180, 220].map((w, i) => (
  <div key={i} style={{ marginBottom: 20 }}>
    <div className="skeleton-bar" style={{ width: w * 0.6, height: 10, marginBottom: 8 }} />
    <div className="skeleton-bar" style={{ width: "100%", height: 36, borderRadius: 8 }} />
  </div>
))}
```

---

### Typing Indicator 

Displayed in the message list while the AI model is generating a response (Part 5, Part 17).

```ts
<div style={{
  paddingLeft: 34,
  display: "flex",
  alignItems: "center",
  gap: 6,
  padding: "8px 16px",
}}>
  <Avatar name={respondingPersona.name} size={20} />
  <span style={{ fontSize: 11, color: theme.textMuted, fontWeight: 350 }}>
    {respondingPersona.name} is typing
  </span>
  <span className="typing-dots" />
</div>
```

**Animated dots CSS:**

```css
.typing-dots::after {
  content: '';
  animation: typing-dots 1.4s infinite;
}

@keyframes typing-dots {
  0% { content: ''; }
  25% { content: '.'; }
  50% { content: '..'; }
  75% { content: '...'; }
  100% { content: ''; }
}
```

**Behavior:**

- Appears immediately when `cipher-route` SSE starts (after "routing" status event)  
- Replaced by the streaming message content once first token arrives  
- If the user is scrolled up, a "New message" chip appears at the bottom of the message list instead of auto-scrolling

---

### Progress Indicators 

**File upload progress bar:**

```ts
// Track
<div style={{
  height: 3, borderRadius: 2, background: theme.surfaceAlt, overflow: "hidden", marginTop: 4,
}}>
  // Fill
  <div style={{
    height: "100%", borderRadius: 2, background: theme.accent,
    width: `${progress}%`, transition: "width 0.2s ease",
  }} />
</div>
```

On failure: fill background changes to `#ef4444`.

**Export progress:**

The ExportModal shows a progress state:

```ts
<div style={{ textAlign: "center", padding: "20px 0" }}>
  <RefreshCw size={20} className="animate-spin" color={theme.textMuted} />
  <div style={{ fontSize: 12, fontWeight: 350, color: theme.textMuted, marginTop: 8 }}>
    Generating {format} export...
  </div>
</div>
```

On completion: replaced by download link. On failure: replaced by error display (Section 21.3).

**Browser page loading:**

A thin progress bar appears at the top of the viewport iframe:

```ts
<div style={{
  position: "absolute", top: 0, left: 0, right: 0, height: 2, zIndex: 10,
}}>
  <div style={{
    height: "100%", background: theme.accent,
    width: isLoading ? "70%" : "100%",
    transition: isLoading ? "width 8s ease-out" : "width 0.3s ease",
    opacity: isLoading ? 1 : 0,
  }} />
</div>
```

Animates to 70% over 8 seconds (slow ease), then jumps to 100% and fades out when loading completes.

**Team execution progress:**

While a team is running, the run card shows:

```ts
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
  <RefreshCw size={12} className="animate-spin" color={theme.accent} />
  <span style={{ fontSize: 11, fontWeight: 400, color: theme.textMuted }}>
    Running... {completedSteps}/{totalSteps} steps
  </span>
</div>
```

Updated every 3 seconds via polling (Part 10, Section 10.8).

---

### Button Loading States 

When a button triggers an async action, it shows a loading state:

```ts
<button disabled={isPending} style={{ opacity: isPending ? 0.6 : 1, cursor: isPending ? "not-allowed" : "pointer" }}>
  {isPending ? (
    <RefreshCw size={12} className="animate-spin" style={{ marginRight: 6 }} />
  ) : null}
  {isPending ? "Saving..." : "Save"}
</button>
```

**Buttons with loading states:**

| Button | Loading Text | Context |
| :---- | :---- | :---- |
| Send (chat) | Replaced by Stop button | Message streaming |
| Save (settings) | "Saving..." | Profile/preference update |
| Create (persona/instance/team) | "Creating..." | Entity creation |
| Export | Replaced by progress indicator | Export generation |
| Upload | Replaced by progress bar | File upload |
| Run Now (team) | "Starting..." → switches to progress | Team execution |
| Validate (API key) | Spinner icon | Key validation |
| Delete | "Deleting..." | Destructive action |

---

## 21.5 Toast Notifications 

Toasts are the primary feedback mechanism for transient success and error messages. Managed by `useUIStore.showToast()` (Part 20, Section 20.4).

### Appearance 

```ts
{
  position: "fixed",
  bottom: 20,
  right: 20,
  zIndex: 100,
  display: "flex",
  flexDirection: "column",
  gap: 8,
}
```

Each toast:

```ts
{
  padding: "10px 14px",
  borderRadius: 10,
  fontSize: 12,
  fontWeight: 400,
  display: "flex",
  alignItems: "center",
  gap: 8,
  minWidth: 260,
  maxWidth: 400,
  boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
  animation: "slideUp 0.2s ease",
}
```

**Type variants:**

| Type | Background | Border | Icon |
| :---- | :---- | :---- | :---- |
| `info` | `surface` | `1px solid ${border}` | Info (14px, `textMuted`) |
| `success` | `surface` | `1px solid rgba(34,197,94,0.2)` | Check (14px, `#22c55e`) |
| `error` | `surface` | `1px solid rgba(239,68,68,0.2)` | AlertCircle (14px, `#ef4444`) |
| `warning` | `surface` | `1px solid rgba(245,158,11,0.2)` | AlertTriangle (14px, `#f59e0b`) |

### Behavior 

- Default duration: 4000ms (auto-dismiss)  
- Duration 0: persistent (requires manual dismiss)  
- Maximum 3 visible toasts at once; older ones are removed  
- Dismiss: X icon (12px) on right side  
- On mobile: full-width, bottom: 12px, right: 12px, left: 12px

### Common Toast Messages 

| Event | Type | Message | Duration |
| :---- | :---- | :---- | :---- |
| Chat created | success | "New conversation created" | 3000 |
| Chat renamed | success | "Chat renamed" | 3000 |
| Chat deleted | success | "Chat moved to trash" | 4000 |
| Chat restored | success | "Chat restored" | 3000 |
| Persona created | success | "Persona created" | 3000 |
| Persona archived | success | "Persona archived" | 3000 |
| Instance created | success | "Instance created" | 3000 |
| File uploaded | success | "{filename} uploaded" | 3000 |
| Files deleted | success | "{count} files deleted" | 3000 |
| Export ready | success | "Export ready for download" | 0 (persistent, click to download) |
| Team run started | info | "Team execution started" | 4000 |
| Team run completed | success | "Team run completed" | 4000 |
| Team run failed | error | "Team run failed. Check the run details." | 0 |
| Settings saved | success | "Settings saved" | 3000 |
| API key added | success | "API key added successfully" | 3000 |
| API key invalid | error | "API key validation failed" | 4000 |
| Memory deactivated | success | "Memory deactivated" | 3000 |
| Copied to clipboard | success | "Copied to clipboard" | 2000 |
| Rate limit hit | warning | "Message limit reached. Resets in {time}." | 0 |
| Session expired | warning | "Your session expired. Please sign in again." | 0 |
| Network error | error | "Connection lost. Please check your internet." | 0 |
| Storage full | warning | "Storage is full. Delete files or upgrade." | 0 |
| Upgrade required | info | "This feature requires {tier} plan." | 4000 |

---

## 21.6 Confirmation Dialogs 

Destructive actions require confirmation via the `ConfirmDialog` component (Part 20, Section 20.9).

### Appearance 

```ts
// Overlay
{
  position: "fixed",
  inset: 0,
  background: "rgba(0,0,0,0.4)",
  zIndex: 200,
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
}

// Dialog
{
  background: theme.surface,
  borderRadius: 16,
  padding: "24px",
  width: 360,
  maxWidth: "90vw",
  boxShadow: "0 8px 30px rgba(0,0,0,0.15)",
}
```

### Actions Requiring Confirmation 

| Action | Title | Message | Confirm Label | Variant |
| :---- | :---- | :---- | :---- | :---- |
| Delete chat (soft) | "Delete conversation?" | "This conversation will be moved to trash. You can restore it within 30 days." | "Delete" | destructive |
| Delete chat (permanent) | "Delete permanently?" | "This conversation will be permanently deleted. This cannot be undone." | "Delete Forever" | destructive |
| Archive persona | "Archive {name}?" | "This persona will be removed from active use. Their memories and skills will be preserved." | "Archive" | default |
| Delete persona | "Delete {name}?" | "This persona and all their memories, skills, and boundaries will be permanently deleted." | "Delete" | destructive |
| Delete instance | "Delete {name}?" | "This instance and all its tasks and workspace items will be deleted. Chats and files will be unlinked but not deleted." | "Delete" | destructive |
| Delete file | "Delete {filename}?" | "This file will be permanently deleted from storage." | "Delete" | destructive |
| Delete files (bulk) | "Delete {count} files?" | "These files will be permanently deleted. This cannot be undone." | "Delete All" | destructive |
| Remove API key | "Remove {provider} key?" | "This API key will be revoked. You can add a new one at any time." | "Remove" | destructive |
| Delete team | "Delete {name}?" | "This team and all its run history will be permanently deleted." | "Delete" | destructive |
| Delete instruction rule | "Delete this rule?" | "Cipher will no longer follow this behavioral rule." | "Delete" | destructive |
| Clear all memories | "Clear all memories for {name}?" | "All memories for this persona will be permanently deleted. This cannot be undone." | "Clear All" | destructive |
| Sign out | "Sign out?" | "You'll need to sign in again to access your workspace." | "Sign Out" | default |

**Destructive variant:** Confirm button uses red background (`#ef4444` bg, white text).

**Default variant:** Confirm button uses accent background.

Both variants include a "Cancel" button (bordered, `text` color) that closes the dialog without action.

---

## 21.7 Offline Behavior 

aiConnected is a cloud-first application and does not support offline mode. However, it handles intermittent connectivity gracefully.

### Detection 

```ts
// src/hooks/use-online-status.ts
export function useOnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine);
  useEffect(() => {
    const handleOnline = () => setOnline(true);
    const handleOffline = () => setOnline(false);
    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);
    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);
  return online;
}
```

### Offline Banner 

When `online === false`, a persistent banner appears at the top of the content area:

```ts
<ErrorBanner
  type="warning"
  message="You're offline. Some features may not work."
  dismissible={false}
/>
```

When connectivity returns:

1. Banner auto-dismisses  
2. All stale queries are refetched via `queryClient.refetchQueries({ type: "active" })`  
3. Supabase Realtime reconnects automatically (Part 17, Section 17.7)  
4. Toast: "Connection restored" (success, 3000ms)

### Optimistic Actions While Offline 

TanStack Query mutations that fail due to network errors are marked as failed (no silent retry). The UI shows the inline error with a retry button. When connectivity returns, the user can retry manually.

---

## 21.8 Cross-Reference 

| State Category | Source Part(s) |
| :---- | :---- |
| Home empty state (first-time user, partial) | Part 4 (Sections 4.6, 4.7) |
| Chat empty states (no chats, no messages, no filter matches) | Part 5 (Sections 5.2, 5.5, 5.6) |
| Search empty/error/loading states | Part 6 (Section 6.7) |
| Spaces empty state | Part 7 (Section 7.2) |
| Instance detail empty states (per tab) | Part 7 (Sections 7.3–7.5) |
| Files empty/loading/upload states | Part 8 (Sections 8.8, 8.12) |
| Persona empty states (per tab) | Part 9 |
| Teams empty/loading/error states | Part 10 (Section 10.4) |
| Browser empty states, proxy errors | Part 11 (Section 11.3) |
| Insights empty/partial data | Part 12 (Section 12.9) |
| Settings empty states (API keys, rules, types) | Part 13 (Sections 13.5, 13.6, 13.8) |
| AI error classification and retry | Part 14 (Section 14.12) |
| SSE stream error events | Part 16 (Section 16.3) |
| Realtime reconnection handling | Part 17 (Section 17.7) |
| Toast and modal components | Part 20 (Sections 20.4, 20.9) |
| Tier gate feature matrix | Part 2 (Section 2.3) |

---

*End of Part 21\. Proceed to Part 22: Testing & Quality Assurance.*

# PART 22: TESTING & QUALITY ASSURANCE 

---

This part defines the complete testing strategy for aiConnected: the testing frameworks, the test directory structure, the four test categories (unit, integration, component, end-to-end), a full test catalog covering every critical function and flow, mock strategies for external dependencies, CI pipeline integration, performance benchmarks, and acceptance criteria for launch readiness.

---

## 22.1 Testing Frameworks & Tooling 

| Tool | Purpose | Configuration |
| :---- | :---- | :---- |
| Vitest | Unit and integration tests | Fast, Vite-native, TypeScript-first. Used for all non-browser tests. |
| React Testing Library | Component rendering and interaction | DOM-based assertions. Renders components in a JSDOM environment. |
| Playwright | End-to-end tests | Real browser automation (Chromium, Firefox, WebKit). Tests critical user flows. |
| MSW (Mock Service Worker) | API mocking | Intercepts network requests in both Vitest (node) and Playwright (browser) environments. |
| Supabase CLI | Local database | `supabase start` spins up a full local Supabase stack (Postgres, Auth, Realtime, Edge Functions) for integration tests. |
| Faker.js | Test data generation | Creates realistic fake data for personas, chats, messages, files. |

### Installation 

```shell
# Test frameworks
npm install -D vitest @vitest/coverage-v8 @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

# API mocking
npm install -D msw

# E2E
npm install -D @playwright/test

# Test utilities
npm install -D @faker-js/faker
```

### Vitest Configuration 

```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./tests/setup.ts"],
    include: [
      "tests/unit/**/*.test.ts",
      "tests/integration/**/*.test.ts",
      "tests/components/**/*.test.tsx",
    ],
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/types/**",
        "src/app/**/layout.tsx",
        "src/app/**/page.tsx",
      ],
      thresholds: {
        statements: 70,
        branches: 60,
        functions: 70,
        lines: 70,
      },
    },
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});
```

### Playwright Configuration 

```ts
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests/e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI ? "github" : "html",
  timeout: 60_000,
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "mobile-chrome", use: { ...devices["Pixel 5"] } },
  ],
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
  },
});
```

### Test Setup 

```ts
// tests/setup.ts
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { server } from "./mocks/server";

// Start MSW server
beforeAll(() => server.listen({ onUnhandledRequest: "warn" }));
afterEach(() => {
  cleanup();
  server.resetHandlers();
});
afterAll(() => server.close());

// Mock Next.js router
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
    prefetch: vi.fn(),
  }),
  usePathname: () => "/",
  useSearchParams: () => new URLSearchParams(),
}));

// Mock Supabase client
vi.mock("@/lib/supabase/client", () => ({
  createClient: () => mockSupabaseClient,
}));
```

---

## 22.2 Test Directory Structure 

```
tests/
  setup.ts                              — Global setup (MSW, mocks, cleanup)
  mocks/
    server.ts                           — MSW server initialization
    handlers.ts                         — Default API request handlers
    factories/
      profiles.ts                       — Profile test data factory
      chats.ts                          — Chat + message factories
      personas.ts                       — Persona + skills + memories factories
      instances.ts                      — Instance + tasks factories
      teams.ts                          — Team + member + run factories
      files.ts                          — File metadata factories
      models.ts                         — Model registry factories
    supabase.ts                         — Mock Supabase client with chainable query builder
    auth.ts                             — Mock auth context (user, session, profile)

  unit/
    lib/
      tier-gates.test.ts
      formatting.test.ts
      constants.test.ts
      theme.test.ts
    stores/
      ui-store.test.ts
      navigation-store.test.ts
      browser-store.test.ts
    cipher/
      routing-algorithm.test.ts
      context-assembly.test.ts
      memory-extraction.test.ts
      skill-matching.test.ts
      token-budgeting.test.ts
    edge-functions/
      cipher-route.test.ts
      cipher-memory.test.ts
      cipher-cleanup.test.ts
      cipher-health.test.ts
      search.test.ts
      analytics.test.ts
      chat-export.test.ts
      browser-proxy.test.ts
      store-api-key.test.ts
      validate-api-key.test.ts
      stripe-webhook.test.ts
      create-checkout-session.test.ts
      files-zip.test.ts
      generate-embedding.test.ts
      team-execute.test.ts

  integration/
    database/
      rls-policies.test.ts
      rpc-functions.test.ts
      triggers.test.ts
      migrations.test.ts
    auth/
      signup-flow.test.ts
      login-flow.test.ts
      oauth-callback.test.ts
      session-refresh.test.ts
    realtime/
      chat-messages.test.ts
      typing-indicators.test.ts
      notifications.test.ts
      persona-updates.test.ts
    pipelines/
      message-to-response.test.ts
      memory-extraction-to-storage.test.ts
      team-execution.test.ts
      file-upload-to-storage.test.ts
      export-generation.test.ts

  components/
    shell/
      AppShell.test.tsx
      Sidebar.test.tsx
      TopBar.test.tsx
      CommandPalette.test.tsx
    screens/
      HomeScreen.test.tsx
      ChatScreen.test.tsx
      SearchScreen.test.tsx
      SpacesScreen.test.tsx
      InstanceDetailScreen.test.tsx
      FilesScreen.test.tsx
      PeopleScreen.test.tsx
      PersonaDetailScreen.test.tsx
      TeamsScreen.test.tsx
      BrowserScreen.test.tsx
      InsightsScreen.test.tsx
      SettingsScreen.test.tsx
    atoms/
      Avatar.test.tsx
      StatusDot.test.tsx
      MemoryTypeIcon.test.tsx
      FilterChip.test.tsx
      TierBadge.test.tsx
    shared/
      EmptyState.test.tsx
      ErrorBanner.test.tsx
      LoadingSkeleton.test.tsx
      ConfirmDialog.test.tsx
    modals/
      ExportModal.test.tsx
      PricingModal.test.tsx
      CreatePersonaModal.test.tsx
      CreateInstanceModal.test.tsx
    chat/
      MessageList.test.tsx
      MessageComposer.test.tsx
      ChatDrawer.test.tsx
      PersonaSelector.test.tsx
      ModelSelector.test.tsx

  e2e/
    auth.spec.ts                        — Sign up, sign in, OAuth, sign out
    onboarding.spec.ts                  — First-time user flow
    chat-core.spec.ts                   — Create chat, send message, receive response
    chat-management.spec.ts             — Pin, archive, rename, delete, restore
    personas.spec.ts                    — Create, edit, archive persona
    instances.spec.ts                   — Create, edit, manage instance
    files.spec.ts                       — Upload, view, delete files
    search.spec.ts                      — Search across content types
    teams.spec.ts                       — Create team, execute run
    browser.spec.ts                     — Open session, navigate, extract
    settings.spec.ts                    — Update preferences, add API key
    tier-gating.spec.ts                 — Feature access by tier
    mobile.spec.ts                      — Responsive behavior checks
    accessibility.spec.ts               — Keyboard navigation, screen reader
```

---

## 22.3 Mock Infrastructure 

### Mock Supabase Client 

A chainable mock that simulates the Supabase JS SDK query builder without network calls.

```ts
// tests/mocks/supabase.ts
import { vi } from "vitest";

interface MockQueryResult {
  data: any;
  error: null | { message: string; code: string };
  count?: number;
}

function createMockQueryBuilder(defaultData: any = []) {
  const builder: any = {
    _data: defaultData,
    _single: false,
    _error: null,

    select: vi.fn().mockReturnThis(),
    insert: vi.fn().mockReturnThis(),
    update: vi.fn().mockReturnThis(),
    delete: vi.fn().mockReturnThis(),
    upsert: vi.fn().mockReturnThis(),
    eq: vi.fn().mockReturnThis(),
    neq: vi.fn().mockReturnThis(),
    in: vi.fn().mockReturnThis(),
    is: vi.fn().mockReturnThis(),
    not: vi.fn().mockReturnThis(),
    ilike: vi.fn().mockReturnThis(),
    order: vi.fn().mockReturnThis(),
    limit: vi.fn().mockReturnThis(),
    range: vi.fn().mockReturnThis(),
    single: vi.fn(function (this: any) {
      this._single = true;
      return this;
    }),

    then: vi.fn(function (this: any, resolve: Function) {
      const result: MockQueryResult = {
        data: this._single ? this._data[0] ?? null : this._data,
        error: this._error,
      };
      return resolve(result);
    }),
  };

  return builder;
}

export const mockSupabaseClient = {
  from: vi.fn((table: string) => createMockQueryBuilder()),
  rpc: vi.fn(() => Promise.resolve({ data: null, error: null })),
  auth: {
    getSession: vi.fn(() => Promise.resolve({ data: { session: mockSession }, error: null })),
    getUser: vi.fn(() => Promise.resolve({ data: { user: mockUser }, error: null })),
    signInWithPassword: vi.fn(),
    signUp: vi.fn(),
    signOut: vi.fn(),
    onAuthStateChange: vi.fn(() => ({ data: { subscription: { unsubscribe: vi.fn() } } })),
  },
  storage: {
    from: vi.fn(() => ({
      upload: vi.fn(() => Promise.resolve({ data: { path: "test/file.pdf" }, error: null })),
      download: vi.fn(() => Promise.resolve({ data: new Blob(), error: null })),
      createSignedUrl: vi.fn(() => Promise.resolve({ data: { signedUrl: "https://example.com/signed" }, error: null })),
      remove: vi.fn(() => Promise.resolve({ data: null, error: null })),
    })),
  },
  functions: {
    invoke: vi.fn(() => Promise.resolve({ data: {}, error: null })),
  },
  channel: vi.fn(() => ({
    on: vi.fn().mockReturnThis(),
    subscribe: vi.fn(() => ({ status: "SUBSCRIBED" })),
    unsubscribe: vi.fn(),
    send: vi.fn(),
  })),
  removeChannel: vi.fn(),
};
```

### Test Data Factories 

```ts
// tests/mocks/factories/personas.ts
import { faker } from "@faker-js/faker";

export function buildPersona(overrides: Partial<Persona> = {}): Persona {
  return {
    id: faker.string.uuid(),
    user_id: faker.string.uuid(),
    name: faker.person.firstName(),
    role: faker.helpers.arrayElement(["Research Assistant", "Writing Coach", "Code Reviewer", "Project Manager"]),
    purpose: faker.lorem.sentence(),
    personality: faker.lorem.sentence(),
    voice: faker.helpers.arrayElement(["professional", "casual", "academic"]),
    avatar: "",
    color: faker.color.rgb(),
    status: "active",
    mood: null,
    settings: {},
    is_default: false,
    created_from_template: null,
    created_at: faker.date.recent().toISOString(),
    updated_at: faker.date.recent().toISOString(),
    ...overrides,
  };
}

export function buildPersonaSkill(overrides: Partial<PersonaSkill> = {}): PersonaSkill {
  return {
    id: faker.string.uuid(),
    persona_id: faker.string.uuid(),
    name: faker.helpers.arrayElement(["JavaScript", "Technical Writing", "Data Analysis", "UX Research"]),
    category: faker.helpers.arrayElement(["coding", "creative", "research", "writing", "planning", "reasoning", "general"]),
    level: faker.helpers.arrayElement(["beginner", "intermediate", "advanced", "expert"]),
    description: faker.lorem.sentence(),
    temporary: false,
    expires_at: null,
    created_at: faker.date.recent().toISOString(),
    ...overrides,
  };
}

export function buildPersonaMemory(overrides: Partial<PersonaMemory> = {}): PersonaMemory {
  return {
    id: faker.string.uuid(),
    persona_id: faker.string.uuid(),
    type: faker.helpers.arrayElement(["decision", "fact", "preference", "skill", "procedure"]),
    content: faker.lorem.sentence(),
    source_chat_id: faker.string.uuid(),
    source_message_id: faker.string.uuid(),
    confidence: faker.helpers.arrayElement(["strong", "medium", "weak"]),
    tags: [faker.word.noun(), faker.word.noun()],
    active: true,
    layer: faker.helpers.arrayElement(["open", "closed"]),
    expires_at: null,
    created_at: faker.date.recent().toISOString(),
    updated_at: faker.date.recent().toISOString(),
    ...overrides,
  };
}

// tests/mocks/factories/chats.ts
export function buildChat(overrides: Partial<Chat> = {}): Chat {
  return {
    id: faker.string.uuid(),
    user_id: faker.string.uuid(),
    title: faker.lorem.words(3),
    auto_title: null,
    instance_id: null,
    pinned: false,
    archived: false,
    metadata: {},
    created_at: faker.date.recent().toISOString(),
    updated_at: faker.date.recent().toISOString(),
    deleted_at: null,
    ...overrides,
  };
}

export function buildMessage(overrides: Partial<Message> = {}): Message {
  return {
    id: faker.string.uuid(),
    chat_id: faker.string.uuid(),
    sender_type: faker.helpers.arrayElement(["user", "persona"]),
    sender_id: faker.string.uuid(),
    sender_name: faker.person.firstName(),
    content: faker.lorem.paragraph(),
    content_blocks: [],
    model_used: null,
    routing_reason: null,
    token_count: null,
    latency_ms: null,
    pinned: false,
    has_link: false,
    has_media: false,
    metadata: {},
    created_at: faker.date.recent().toISOString(),
    deleted_at: null,
    ...overrides,
  };
}

// Additional factories: buildInstance, buildTask, buildTeam, buildTeamMember,
// buildFile, buildProfile, buildModelRegistry, buildApiKey, etc.
// Each follows the same pattern: faker defaults + spread overrides.
```

### MSW Handlers 

```ts
// tests/mocks/handlers.ts
import { http, HttpResponse } from "msw";

const SUPABASE_URL = "http://localhost:54321";

export const handlers = [
  // PostgREST table queries
  http.get(`${SUPABASE_URL}/rest/v1/chats`, () => {
    return HttpResponse.json([buildChat(), buildChat(), buildChat()]);
  }),

  http.get(`${SUPABASE_URL}/rest/v1/personas`, () => {
    return HttpResponse.json([buildPersona(), buildPersona()]);
  }),

  http.get(`${SUPABASE_URL}/rest/v1/instances`, () => {
    return HttpResponse.json([buildInstance(), buildInstance()]);
  }),

  http.get(`${SUPABASE_URL}/rest/v1/messages`, () => {
    return HttpResponse.json([
      buildMessage({ sender_type: "user" }),
      buildMessage({ sender_type: "persona" }),
    ]);
  }),

  // RPC functions
  http.post(`${SUPABASE_URL}/rest/v1/rpc/search_chat_titles`, () => {
    return HttpResponse.json([]);
  }),

  // Edge Functions
  http.post(`${SUPABASE_URL}/functions/v1/cipher-route`, () => {
    const stream = new ReadableStream({
      start(controller) {
        const encoder = new TextEncoder();
        controller.enqueue(encoder.encode('data: {"type":"status","status":"routing"}\n\n'));
        controller.enqueue(encoder.encode('data: {"type":"token","content":"Hello"}\n\n'));
        controller.enqueue(encoder.encode('data: {"type":"token","content":" world"}\n\n'));
        controller.enqueue(encoder.encode('data: {"type":"done","message_id":"test-id","model_used":"test-model","token_count":2,"latency_ms":100}\n\n'));
        controller.close();
      },
    });
    return new HttpResponse(stream, {
      headers: { "Content-Type": "text/event-stream" },
    });
  }),

  http.post(`${SUPABASE_URL}/functions/v1/search`, () => {
    return HttpResponse.json({ results: [], total: 0, query: "", execution_time_ms: 50 });
  }),

  http.post(`${SUPABASE_URL}/functions/v1/analytics`, () => {
    return HttpResponse.json({ total_chats: 10, total_messages: 50 });
  }),
];
```

---

## 22.4 Unit Tests 

Unit tests verify pure functions, utility logic, store behavior, and isolated algorithms. They run in Vitest's JSDOM environment with no database or network dependencies.

### Utility Functions 

```ts
// tests/unit/lib/tier-gates.test.ts
describe("getTierLimits", () => {
  it("returns correct limits for free tier", () => {
    const limits = getTierLimits("free");
    expect(limits.maxPersonas).toBe(2);
    expect(limits.maxInstances).toBe(3);
    expect(limits.maxChatsPerDay).toBe(5);
    expect(limits.allowTeams).toBe(false);
    expect(limits.allowBrowser).toBe(false);
  });

  it("returns correct limits for plus tier", () => {
    const limits = getTierLimits("plus");
    expect(limits.maxPersonas).toBe(5);
    expect(limits.allowFileUploads).toBe(true);
    expect(limits.allowMultiModelRouting).toBe(true);
  });

  it("returns correct limits for premium tier", () => {
    const limits = getTierLimits("premium");
    expect(limits.allowTeams).toBe(true);
    expect(limits.allowBrowser).toBe(true);
    expect(limits.allowInstanceTypes).toBe(true);
  });

  it("returns correct limits for pro tier", () => {
    const limits = getTierLimits("pro");
    expect(limits.maxChatsPerDay).toBe(-1);
    expect(limits.allowEncryptedExport).toBe(true);
    expect(limits.allowApiAccess).toBe(true);
  });

  it("defaults to free for unknown tier", () => {
    const limits = getTierLimits("invalid" as any);
    expect(limits.maxPersonas).toBe(2);
  });

  it("export format availability scales with tier", () => {
    expect(getTierLimits("free").exportFormats).toEqual(["markdown"]);
    expect(getTierLimits("plus").exportFormats).toContain("json");
    expect(getTierLimits("premium").exportFormats).toContain("pdf");
    expect(getTierLimits("pro").exportFormats).toContain("encrypted_zip");
  });
});
```

```ts
// tests/unit/lib/formatting.test.ts
describe("formatting utilities", () => {
  it("formatRelativeTime returns 'just now' for recent timestamps", () => {
    expect(formatRelativeTime(new Date())).toBe("just now");
  });

  it("formatRelativeTime returns '2h ago' for 2-hour-old timestamps", () => {
    const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
    expect(formatRelativeTime(twoHoursAgo)).toBe("2h ago");
  });

  it("formatBytes converts bytes to human-readable sizes", () => {
    expect(formatBytes(0)).toBe("0 B");
    expect(formatBytes(1024)).toBe("1.0 KB");
    expect(formatBytes(1_073_741_824)).toBe("1.0 GB");
  });

  it("formatTokenCount adds commas for thousands", () => {
    expect(formatTokenCount(1234)).toBe("1,234");
    expect(formatTokenCount(1_000_000)).toBe("1,000,000");
  });

  it("sanitizeFilename removes special characters", () => {
    expect(sanitizeFilename("Hello World! @#$")).toBe("hello-world");
    expect(sanitizeFilename("   spaces   ")).toBe("spaces");
  });

  it("truncateText adds ellipsis at word boundaries", () => {
    expect(truncateText("Hello world this is a test", 15)).toBe("Hello world...");
  });
});
```

### Zustand Stores 

```ts
// tests/unit/stores/ui-store.test.ts
describe("useUIStore", () => {
  beforeEach(() => useUIStore.setState(useUIStore.getInitialState()));

  it("toggles sidebar expansion", () => {
    expect(useUIStore.getState().sidebarExpanded).toBe(true);
    useUIStore.getState().toggleSidebar();
    expect(useUIStore.getState().sidebarExpanded).toBe(false);
    useUIStore.getState().toggleSidebar();
    expect(useUIStore.getState().sidebarExpanded).toBe(true);
  });

  it("opens and closes modals with props", () => {
    useUIStore.getState().openModal("export", { chatId: "abc" });
    expect(useUIStore.getState().activeModal).toBe("export");
    expect(useUIStore.getState().modalProps).toEqual({ chatId: "abc" });

    useUIStore.getState().closeModal();
    expect(useUIStore.getState().activeModal).toBeNull();
    expect(useUIStore.getState().modalProps).toEqual({});
  });

  it("manages toast lifecycle", () => {
    vi.useFakeTimers();
    useUIStore.getState().showToast("Test message", "success", 3000);
    expect(useUIStore.getState().toasts).toHaveLength(1);
    expect(useUIStore.getState().toasts[0].message).toBe("Test message");

    vi.advanceTimersByTime(3000);
    expect(useUIStore.getState().toasts).toHaveLength(0);
    vi.useRealTimers();
  });

  it("limits visible toasts (max 3 enforced by UI, store allows any count)", () => {
    for (let i = 0; i < 5; i++) {
      useUIStore.getState().showToast(`Toast ${i}`, "info", 0);
    }
    expect(useUIStore.getState().toasts).toHaveLength(5);
  });

  it("toggles right panel", () => {
    expect(useUIStore.getState().rightPanelOpen).toBe(false);
    useUIStore.getState().toggleRightPanel();
    expect(useUIStore.getState().rightPanelOpen).toBe(true);
  });

  it("sets right panel tab and opens panel", () => {
    useUIStore.getState().setRightPanelTab("memories");
    expect(useUIStore.getState().rightPanelTab).toBe("memories");
    expect(useUIStore.getState().rightPanelOpen).toBe(true);
  });
});

// tests/unit/stores/navigation-store.test.ts
describe("useNavigationStore", () => {
  beforeEach(() => useNavigationStore.setState(useNavigationStore.getInitialState()));

  it("manages multi-select toggle", () => {
    useNavigationStore.getState().toggleSelected("a");
    useNavigationStore.getState().toggleSelected("b");
    expect(useNavigationStore.getState().selectedIds.size).toBe(2);

    useNavigationStore.getState().toggleSelected("a");
    expect(useNavigationStore.getState().selectedIds.size).toBe(1);
    expect(useNavigationStore.getState().selectedIds.has("b")).toBe(true);
  });

  it("selectAll replaces current selection", () => {
    useNavigationStore.getState().toggleSelected("x");
    useNavigationStore.getState().selectAll(["a", "b", "c"]);
    expect(useNavigationStore.getState().selectedIds.size).toBe(3);
    expect(useNavigationStore.getState().selectedIds.has("x")).toBe(false);
  });

  it("clearSelection empties the set", () => {
    useNavigationStore.getState().selectAll(["a", "b"]);
    useNavigationStore.getState().clearSelection();
    expect(useNavigationStore.getState().selectedIds.size).toBe(0);
  });
});
```

### Cipher Routing Algorithm 

```ts
// tests/unit/cipher/routing-algorithm.test.ts
describe("Cipher routing algorithm", () => {
  const atlas = buildPersona({ name: "Atlas", id: "p1" });
  const nova = buildPersona({ name: "Nova", id: "p2" });
  const atlasSkills = [buildPersonaSkill({ persona_id: "p1", category: "writing", level: "expert" })];
  const novaSkills = [buildPersonaSkill({ persona_id: "p2", category: "coding", level: "advanced" })];

  it("routes to directed persona when @mentioned", () => {
    const result = routeMessage({
      content: "Help me write an essay",
      directedPersonaId: "p2",
      participants: [atlas, nova],
      skills: { p1: atlasSkills, p2: novaSkills },
    });
    expect(result.personaId).toBe("p2");
    expect(result.reason).toBe("directed");
  });

  it("routes to skill-matched persona when no @mention", () => {
    const result = routeMessage({
      content: "Help me write an essay about AI",
      directedPersonaId: null,
      participants: [atlas, nova],
      skills: { p1: atlasSkills, p2: novaSkills },
    });
    expect(result.personaId).toBe("p1");
    expect(result.reason).toBe("skill_match");
  });

  it("routes to primary persona when no skill match", () => {
    const result = routeMessage({
      content: "What do you think about this?",
      directedPersonaId: null,
      participants: [atlas, nova],
      skills: { p1: [], p2: [] },
      primaryPersonaId: "p1",
    });
    expect(result.personaId).toBe("p1");
    expect(result.reason).toBe("primary");
  });

  it("routes to default persona in single-participant chat", () => {
    const result = routeMessage({
      content: "Hello",
      directedPersonaId: null,
      participants: [atlas],
      skills: { p1: atlasSkills },
    });
    expect(result.personaId).toBe("p1");
    expect(result.reason).toBe("sole_participant");
  });

  it("handles empty participant list gracefully", () => {
    expect(() => routeMessage({
      content: "Hello",
      directedPersonaId: null,
      participants: [],
      skills: {},
    })).toThrow("No participants available");
  });
});
```

### Token Budgeting 

```ts
// tests/unit/cipher/token-budgeting.test.ts
describe("Token budgeting", () => {
  it("allocates budget according to priority tiers", () => {
    const budget = allocateTokenBudget({
      contextWindow: 128000,
      maxOutput: 4096,
      systemPromptTokens: 500,
      memoriesCount: 10,
      messagesCount: 50,
    });
    expect(budget.system).toBeGreaterThan(0);
    expect(budget.memories).toBeGreaterThan(0);
    expect(budget.messages).toBeGreaterThan(0);
    expect(budget.system + budget.memories + budget.messages + budget.reserved).toBeLessThanOrEqual(128000 - 4096);
  });

  it("truncates messages when exceeding budget", () => {
    const messages = Array.from({ length: 100 }, (_, i) => buildMessage({ content: "x".repeat(500) }));
    const truncated = truncateToFit(messages, 5000);
    expect(truncated.length).toBeLessThan(100);
    // Most recent messages should be preserved
    expect(truncated[truncated.length - 1].id).toBe(messages[messages.length - 1].id);
  });

  it("never truncates the most recent user message", () => {
    const messages = [buildMessage({ sender_type: "user", content: "x".repeat(10000) })];
    const truncated = truncateToFit(messages, 500);
    expect(truncated).toHaveLength(1);
  });
});
```

### Memory Extraction 

```ts
// tests/unit/cipher/memory-extraction.test.ts
describe("Memory extraction", () => {
  it("extracts facts from conversation exchanges", () => {
    const result = classifyMemory("The user's preferred language is Python");
    expect(result.type).toBe("preference");
    expect(result.confidence).toBe("strong");
  });

  it("detects decisions", () => {
    const result = classifyMemory("We decided to use Next.js for the frontend");
    expect(result.type).toBe("decision");
  });

  it("detects instruction-type memory", () => {
    const result = classifyInstruction("Always format code blocks with language annotations");
    expect(result.isInstruction).toBe(true);
    expect(result.scope).toBe("global");
  });

  it("rejects trivial content as non-memorable", () => {
    const result = classifyMemory("Okay, sounds good");
    expect(result).toBeNull();
  });
});
```

### Edge Function Logic 

```ts
// tests/unit/edge-functions/store-api-key.test.ts
describe("store-api-key encryption", () => {
  it("encrypts key and returns hint", async () => {
    const result = await encryptAndStore("sk-test-abcdefgh1234", "openrouter");
    expect(result.encryptedKey).not.toContain("sk-test");
    expect(result.keyHint).toBe("...1234");
    expect(result.encryptedKey).toMatch(/^[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$/); // IV:ciphertext base64 format
  });

  it("decrypts to original key", async () => {
    const original = "sk-test-abcdefgh1234";
    const { encryptedKey } = await encryptAndStore(original, "openrouter");
    const decrypted = await decryptKey(encryptedKey);
    expect(decrypted).toBe(original);
  });
});

// tests/unit/edge-functions/stripe-webhook.test.ts
describe("stripe-webhook event handling", () => {
  it("maps price IDs to tiers correctly", () => {
    expect(mapPriceToTier("price_plus_123")).toBe("plus");
    expect(mapPriceToTier("price_premium_456")).toBe("premium");
    expect(mapPriceToTier("price_pro_789")).toBe("pro");
    expect(mapPriceToTier("unknown_price")).toBe("free");
  });
});
```

### Unit Test Coverage Targets 

| Module | Target Coverage | Priority |
| :---- | :---- | :---- |
| `lib/utils/tier-gates.ts` | 100% | Critical — gates all features |
| `lib/utils/formatting.ts` | 95% | High — used in every screen |
| `stores/ui-store.ts` | 90% | High — drives all UI state |
| `stores/navigation-store.ts` | 90% | High — drives navigation |
| `stores/browser-store.ts` | 85% | Medium |
| `lib/cipher/router.ts` | 95% | Critical — routing correctness |
| `lib/cipher/context.ts` | 90% | Critical — token budgeting |
| `lib/cipher/memory.ts` | 85% | High — memory extraction |
| Edge Function logic (extracted) | 80% | High |

---

## 22.5 Integration Tests 

Integration tests verify that multiple components work together against a real (local) Supabase instance. These require `supabase start` to be running.

### Database: RLS Policies 

```ts
// tests/integration/database/rls-policies.test.ts
describe("Row Level Security", () => {
  let userA: SupabaseClient;
  let userB: SupabaseClient;
  let userAId: string;
  let userBId: string;

  beforeAll(async () => {
    // Create two test users via Supabase Auth
    userA = await createAuthenticatedClient("usera@test.com", "password123");
    userB = await createAuthenticatedClient("userb@test.com", "password123");
    userAId = (await userA.auth.getUser()).data.user!.id;
    userBId = (await userB.auth.getUser()).data.user!.id;
  });

  it("user cannot read another user's chats", async () => {
    // User A creates a chat
    await userA.from("chats").insert({ user_id: userAId, title: "Private Chat" });

    // User B tries to read it
    const { data } = await userB.from("chats").select("*");
    expect(data).toHaveLength(0);
  });

  it("user can only update their own profile", async () => {
    const { error } = await userB.from("profiles").update({ display_name: "Hacked" }).eq("id", userAId);
    expect(error).not.toBeNull();
  });

  it("user cannot read another user's messages", async () => {
    const { data: chat } = await userA.from("chats").insert({ user_id: userAId, title: "Test" }).select().single();
    await userA.from("messages").insert({
      chat_id: chat!.id, sender_type: "user", sender_name: "A", content: "Secret",
    });

    const { data: messages } = await userB.from("messages").select("*").eq("chat_id", chat!.id);
    expect(messages).toHaveLength(0);
  });

  it("user can read all model_registry rows", async () => {
    const { data } = await userA.from("model_registry").select("*");
    expect(data!.length).toBeGreaterThan(0);
  });

  it("user cannot write to model_registry", async () => {
    const { error } = await userA.from("model_registry").insert({
      id: "fake-model", display_name: "Fake", provider: "openai", status: "active",
    } as any);
    expect(error).not.toBeNull();
  });

  it("user can manage their own personas", async () => {
    const { data } = await userA.from("personas").insert({
      user_id: userAId, name: "Test Persona", role: "Test",
    }).select().single();
    expect(data).not.toBeNull();

    const { error: updateError } = await userA.from("personas").update({ name: "Renamed" }).eq("id", data!.id);
    expect(updateError).toBeNull();
  });

  // Repeat for all 35 tables: verify own-data access, cross-user isolation,
  // service-role-only tables (token_usage, rate_limit_counters), and
  // subquery-based policies (chat_participants, instance_personas, etc.)
});
```

### Database: RPC Functions 

```ts
// tests/integration/database/rpc-functions.test.ts
describe("RPC functions", () => {
  it("search_chat_titles returns matching chats", async () => {
    await client.from("chats").insert([
      { user_id: userId, title: "AI research paper" },
      { user_id: userId, title: "Grocery list" },
    ]);

    const { data } = await client.rpc("search_chat_titles", {
      p_user_id: userId, p_query: "research", p_limit: 10,
    });
    expect(data).toHaveLength(1);
    expect(data[0].title).toContain("research");
  });

  it("search_persona_memories returns semantically similar results", async () => {
    // Insert memories with embeddings
    // Call RPC with a query embedding
    // Verify returned memories are above threshold
  });

  it("persona_memory_stability computes conflict ratio", async () => {
    // Insert 10 active memories, 2 with contradicts edges
    const { data } = await client.rpc("persona_memory_stability", { p_persona_id: personaId });
    expect(data).toBeCloseTo(0.8, 1); // 80% stability
  });

  it("trim_search_history keeps only 50 entries", async () => {
    // Insert 60 search history entries
    await client.rpc("trim_search_history", { p_user_id: userId });
    const { count } = await client.from("search_history").select("*", { count: "exact" }).eq("user_id", userId);
    expect(count).toBe(50);
  });

  it("get_storage_usage returns correct byte count", async () => {
    // Upload test files
    const { data } = await client.rpc("get_storage_usage", { p_user_id: userId });
    expect(data).toBeGreaterThan(0);
  });
});
```

### Database: Triggers 

```ts
// tests/integration/database/triggers.test.ts
describe("Database triggers", () => {
  it("handle_new_user creates a profile on auth signup", async () => {
    const { data: { user } } = await supabase.auth.signUp({
      email: "trigger-test@test.com", password: "password123",
    });

    const { data: profile } = await serviceClient.from("profiles").select("*").eq("id", user!.id).single();
    expect(profile).not.toBeNull();
    expect(profile!.tier).toBe("free");
    expect(profile!.credits).toBe(100);
  });

  it("seed_model_roles creates 7 default assignments on profile creation", async () => {
    const { data: roles } = await serviceClient.from("model_role_assignments")
      .select("*").eq("user_id", userId);
    expect(roles).toHaveLength(7);
  });

  it("seed_instance_types creates 5 builtin types on profile creation", async () => {
    const { data: types } = await serviceClient.from("instance_types")
      .select("*").eq("user_id", userId).eq("is_builtin", true);
    expect(types).toHaveLength(5);
  });

  it("handle_updated_at auto-updates timestamps on row changes", async () => {
    const { data: chat } = await client.from("chats").insert({ user_id: userId, title: "Test" }).select().single();
    const originalUpdated = chat!.updated_at;

    await new Promise((r) => setTimeout(r, 100));
    await client.from("chats").update({ title: "Updated" }).eq("id", chat!.id);

    const { data: updated } = await client.from("chats").select("updated_at").eq("id", chat!.id).single();
    expect(new Date(updated!.updated_at).getTime()).toBeGreaterThan(new Date(originalUpdated).getTime());
  });
});
```

### Pipelines 

```ts
// tests/integration/pipelines/message-to-response.test.ts
describe("Message → Response pipeline", () => {
  it("inserts user message, calls cipher-route, inserts persona message", async () => {
    // 1. Create chat with persona participant
    const { data: chat } = await client.from("chats").insert({ user_id: userId, title: "Pipeline Test" }).select().single();
    await client.from("chat_participants").insert({ chat_id: chat!.id, persona_id: personaId, is_primary: true });

    // 2. Call cipher-route Edge Function
    const response = await fetch(`${SUPABASE_URL}/functions/v1/cipher-route`, {
      method: "POST",
      headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" },
      body: JSON.stringify({ chat_id: chat!.id, content: "Hello, tell me about testing" }),
    });

    expect(response.status).toBe(200);
    expect(response.headers.get("content-type")).toContain("text/event-stream");

    // 3. Read SSE stream to completion
    const events = await readSSEToCompletion(response);
    const doneEvent = events.find((e: any) => e.type === "done");
    expect(doneEvent).toBeDefined();
    expect(doneEvent.message_id).toBeDefined();

    // 4. Verify messages in database
    const { data: messages } = await client.from("messages").select("*").eq("chat_id", chat!.id).order("created_at");
    expect(messages).toHaveLength(2);
    expect(messages![0].sender_type).toBe("user");
    expect(messages![1].sender_type).toBe("persona");

    // 5. Verify token usage recorded
    const { data: usage } = await serviceClient.from("token_usage").select("*").eq("chat_id", chat!.id);
    expect(usage!.length).toBeGreaterThan(0);
  });

  it("returns 429 when rate limit is exceeded", async () => {
    // Exhaust daily limit by sending maxChatsPerDay + 1 messages
    // Verify 429 response with retryable flag
  });

  it("falls back to fallback model when primary fails", async () => {
    // Mock primary model to return 500
    // Verify response uses fallback model
    // Verify done event includes fallback_used: true
  });
});

// tests/integration/pipelines/file-upload-to-storage.test.ts
describe("File upload → Storage pipeline", () => {
  it("uploads file, creates files row, updates storage usage", async () => {
    const file = new Blob(["test content"], { type: "text/plain" });
    const path = `${userId}/${crypto.randomUUID()}/test.txt`;

    await client.storage.from("user-files").upload(path, file);
    await client.from("files").insert({
      user_id: userId, name: "test.txt", mime_type: "text/plain",
      size_bytes: 12, storage_path: path,
    });

    const { data: usage } = await client.rpc("get_storage_usage", { p_user_id: userId });
    expect(usage).toBe(12);
  });

  it("rejects upload when storage quota is exceeded", async () => {
    // Set profile storage to near limit
    // Attempt upload that would exceed limit
    // Verify client-side rejection
  });
});
```

---

## 22.6 Component Tests 

Component tests render React components with mock data and verify that the correct elements are visible, interactive, and accessible.

```ts
// tests/components/atoms/Avatar.test.tsx
describe("Avatar", () => {
  it("renders first letter of name", () => {
    render(<Avatar name="Atlas" />);
    expect(screen.getByText("A")).toBeInTheDocument();
  });

  it("uses custom size", () => {
    const { container } = render(<Avatar name="Nova" size={48} />);
    expect(container.firstChild).toHaveStyle({ width: "48px", height: "48px" });
  });

  it("applies custom color", () => {
    const { container } = render(<Avatar name="Atlas" color="#ff0000" />);
    expect(container.firstChild).toHaveStyle({ background: "#ff0000" });
  });
});

// tests/components/atoms/StatusDot.test.tsx
describe("StatusDot", () => {
  it("renders active status with green color", () => {
    const { container } = render(<StatusDot status="active" />);
    expect(container.firstChild).toHaveStyle({ background: "#22c55e" });
  });

  it("renders idle status with amber color", () => {
    const { container } = render(<StatusDot status="idle" />);
    expect(container.firstChild).toHaveStyle({ background: "#f59e0b" });
  });
});

// tests/components/shared/EmptyState.test.tsx
describe("EmptyState", () => {
  it("renders title and description", () => {
    render(<EmptyState icon={Search} title="No results" description="Try different keywords" />);
    expect(screen.getByText("No results")).toBeInTheDocument();
    expect(screen.getByText("Try different keywords")).toBeInTheDocument();
  });

  it("renders action button when provided", () => {
    const onClick = vi.fn();
    render(<EmptyState icon={Search} title="Empty" description="" action={{ label: "Create", onClick }} />);
    fireEvent.click(screen.getByText("Create"));
    expect(onClick).toHaveBeenCalledOnce();
  });

  it("does not render action when omitted", () => {
    render(<EmptyState icon={Search} title="Empty" description="" />);
    expect(screen.queryByRole("button")).toBeNull();
  });
});
```

```ts
// tests/components/screens/HomeScreen.test.tsx
describe("HomeScreen", () => {
  it("renders stat cards with data", () => {
    mockSupabaseClient.from.mockReturnValue(createMockQueryBuilder([
      { instances: 3, personas: 2, chats: 15, memories: 42 },
    ]));

    render(<TestWrapper><HomeScreen /></TestWrapper>);
    expect(screen.getByText("3")).toBeInTheDocument();
    expect(screen.getByText("Active Personas")).toBeInTheDocument();
  });

  it("shows empty state for new users", async () => {
    mockSupabaseClient.from.mockReturnValue(createMockQueryBuilder([
      { instances: 0, personas: 0, chats: 0, memories: 0 },
    ]));

    render(<TestWrapper><HomeScreen /></TestWrapper>);
    await waitFor(() => {
      expect(screen.getByText("Create your first Instance")).toBeInTheDocument();
      expect(screen.getByText("Meet your first Persona")).toBeInTheDocument();
      expect(screen.getByText("Start a conversation")).toBeInTheDocument();
    });
  });

  it("renders activity feed", async () => {
    // Mock activity data
    render(<TestWrapper><HomeScreen /></TestWrapper>);
    await waitFor(() => {
      expect(screen.getByText(/Recent Activity/i)).toBeInTheDocument();
    });
  });
});

// tests/components/screens/ChatScreen.test.tsx
describe("ChatScreen", () => {
  it("renders chat list in drawer", async () => {
    mockSupabaseClient.from.mockImplementation((table: string) => {
      if (table === "chats") return createMockQueryBuilder([buildChat({ title: "My Chat" })]);
      return createMockQueryBuilder([]);
    });

    render(<TestWrapper><ChatScreen /></TestWrapper>);
    await waitFor(() => {
      expect(screen.getByText("My Chat")).toBeInTheDocument();
    });
  });

  it("shows 'no chat selected' when no active chat", () => {
    render(<TestWrapper><ChatScreen /></TestWrapper>);
    expect(screen.getByText(/Select a conversation/i)).toBeInTheDocument();
  });

  it("renders messages when a chat is active", async () => {
    // Set active chat ID in navigation store
    // Mock messages for that chat
    // Verify messages render with correct sender names
  });

  it("sends message and shows streaming content", async () => {
    // Type in composer, click send
    // Verify cipher-route is called
    // Verify streaming content appears
  });
});
```

```ts
// tests/components/modals/ExportModal.test.tsx
describe("ExportModal", () => {
  it("shows available formats based on tier", () => {
    // Mock free tier
    render(<TestWrapper><ExportModal chatId="test" /></TestWrapper>);
    expect(screen.getByText("Markdown")).toBeInTheDocument();
    expect(screen.queryByText("PDF")).toBeNull(); // Not available on free
  });

  it("requires passphrase for encrypted zip", async () => {
    // Mock pro tier
    render(<TestWrapper><ExportModal chatId="test" /></TestWrapper>);
    fireEvent.click(screen.getByText("Encrypted ZIP"));
    expect(screen.getByPlaceholderText(/passphrase/i)).toBeInTheDocument();
  });
});
```

### Component Test Coverage Targets 

| Component Category | Coverage | Notes |
| :---- | :---- | :---- |
| Atomic components | 95% | Small, pure; full coverage is achievable |
| Shared components | 90% | EmptyState, ErrorBanner, ConfirmDialog |
| Modal components | 85% | Form validation, tier gating, submission |
| Screen components | 75% | Data loading, empty/error states, primary interactions |
| Shell components | 80% | Sidebar, TopBar, CommandPalette |

---

## 22.7 End-to-End Tests 

E2E tests run against a real application (local dev server \+ local Supabase) in a real browser via Playwright.

### Test Accounts 

```ts
// tests/e2e/fixtures.ts
export const testUsers = {
  free: { email: "e2e-free@test.com", password: "TestPass123!", tier: "free" },
  plus: { email: "e2e-plus@test.com", password: "TestPass123!", tier: "plus" },
  premium: { email: "e2e-premium@test.com", password: "TestPass123!", tier: "premium" },
  pro: { email: "e2e-pro@test.com", password: "TestPass123!", tier: "pro" },
};
```

### Critical Flow Tests 

```ts
// tests/e2e/auth.spec.ts
test.describe("Authentication", () => {
  test("user can sign up with email", async ({ page }) => {
    await page.goto("/auth/signup");
    await page.fill('[name="email"]', "newuser@test.com");
    await page.fill('[name="password"]', "SecurePass123!");
    await page.click('button:has-text("Create Account")');
    await expect(page).toHaveURL("/");
    await expect(page.locator("text=Good")).toBeVisible(); // Dashboard greeting
  });

  test("user can sign in with email", async ({ page }) => {
    await page.goto("/auth/login");
    await page.fill('[name="email"]', testUsers.free.email);
    await page.fill('[name="password"]', testUsers.free.password);
    await page.click('button:has-text("Sign In")');
    await expect(page).toHaveURL("/");
  });

  test("unauthenticated user is redirected to login", async ({ page }) => {
    await page.goto("/chat");
    await expect(page).toHaveURL(/\/auth\/login/);
  });

  test("user can sign out", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.click('[data-testid="user-menu"]');
    await page.click('text=Sign Out');
    await page.click('text=Sign Out'); // Confirm dialog
    await expect(page).toHaveURL(/\/auth\/login/);
  });
});

// tests/e2e/chat-core.spec.ts
test.describe("Chat Core Flow", () => {
  test.beforeEach(async ({ page }) => {
    await signIn(page, testUsers.free);
  });

  test("create a new chat and send a message", async ({ page }) => {
    await page.goto("/chat");
    await page.click('button:has-text("New Chat")');

    // Type and send message
    await page.fill('[data-testid="message-composer"]', "Hello, help me brainstorm");
    await page.click('[data-testid="send-button"]');

    // Verify user message appears
    await expect(page.locator("text=Hello, help me brainstorm")).toBeVisible();

    // Wait for AI response (streaming)
    await expect(page.locator('[data-testid="persona-message"]')).toBeVisible({ timeout: 30000 });
  });

  test("pin and unpin a message", async ({ page }) => {
    await navigateToExistingChat(page);

    // Hover over message to reveal actions
    const message = page.locator('[data-testid="message"]').first();
    await message.hover();
    await message.locator('[data-testid="pin-button"]').click();

    // Verify pin indicator
    await expect(message.locator('[data-testid="pin-icon"]')).toBeVisible();
  });

  test("rename a chat from the drawer", async ({ page }) => {
    await navigateToExistingChat(page);

    const chatItem = page.locator('[data-testid="chat-list-item"]').first();
    await chatItem.click({ button: "right" });
    await page.click('text=Rename');
    await page.fill('[data-testid="rename-input"]', "Renamed Chat");
    await page.keyboard.press("Enter");

    await expect(chatItem.locator("text=Renamed Chat")).toBeVisible();
  });
});

// tests/e2e/personas.spec.ts
test.describe("Persona Management", () => {
  test("create a persona from template", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.goto("/people");
    await page.click('button:has-text("Create Persona")');

    // Select template
    await page.click('[data-testid="template-card"]');

    // Customize name
    await page.fill('[name="name"]', "Research Bot");
    await page.click('button:has-text("Create")');

    // Verify persona appears in grid
    await expect(page.locator("text=Research Bot")).toBeVisible();
  });

  test("add a skill to a persona", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.goto("/people");
    await page.click('[data-testid="persona-card"]');

    // Navigate to skills tab
    await page.click('text=Skills');
    await page.click('button:has-text("Add Skill")');
    await page.fill('[name="skill-name"]', "Data Analysis");
    await page.click('button:has-text("Save")');

    await expect(page.locator("text=Data Analysis")).toBeVisible();
  });
});

// tests/e2e/instances.spec.ts
test.describe("Instance Management", () => {
  test("create an instance and add a persona", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.goto("/spaces");
    await page.click('button:has-text("Create Space")');

    await page.fill('[name="name"]', "Q1 Project");
    await page.fill('[name="description"]', "First quarter deliverables");
    await page.click('button:has-text("Create")');

    await expect(page.locator("text=Q1 Project")).toBeVisible();

    // Add persona
    await page.click("text=Q1 Project");
    await page.click("text=Personas");
    await page.click('button:has-text("Add Persona")');
    // Select persona from list
    await page.click('[data-testid="persona-option"]');
    await expect(page.locator('[data-testid="assigned-persona"]')).toBeVisible();
  });
});

// tests/e2e/tier-gating.spec.ts
test.describe("Tier Gating", () => {
  test("free user cannot access teams", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.goto("/teams");
    await expect(page.locator("text=Unlock Teams")).toBeVisible();
    await expect(page.locator('button:has-text("Upgrade")')).toBeVisible();
  });

  test("premium user can access teams", async ({ page }) => {
    await signIn(page, testUsers.premium);
    await page.goto("/teams");
    await expect(page.locator("text=Unlock Teams")).not.toBeVisible();
  });

  test("free user sees only markdown export format", async ({ page }) => {
    await signIn(page, testUsers.free);
    await navigateToExistingChat(page);
    await openExportModal(page);
    await expect(page.locator("text=Markdown")).toBeVisible();
    await expect(page.locator("text=PDF")).not.toBeVisible();
  });
});

// tests/e2e/search.spec.ts
test.describe("Search", () => {
  test("search finds chats by title", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.goto("/search");
    await page.fill('[data-testid="search-input"]', "brainstorm");
    await page.keyboard.press("Enter");

    // Wait for results
    await expect(page.locator('[data-testid="search-result"]')).toBeVisible({ timeout: 10000 });
  });

  test("search shows no-results state for gibberish", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.goto("/search");
    await page.fill('[data-testid="search-input"]', "xyzzyplugh99999");
    await page.keyboard.press("Enter");

    await expect(page.locator("text=No results")).toBeVisible();
  });
});

// tests/e2e/mobile.spec.ts (runs on mobile-chrome project)
test.describe("Mobile Responsiveness", () => {
  test("sidebar becomes hamburger overlay on mobile", async ({ page }) => {
    await signIn(page, testUsers.free);
    await expect(page.locator('[data-testid="sidebar"]')).not.toBeVisible();
    await page.click('[data-testid="hamburger-menu"]');
    await expect(page.locator('[data-testid="mobile-sidebar"]')).toBeVisible();
  });

  test("chat drawer opens as full-screen sheet on mobile", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.goto("/chat");
    // Verify drawer behavior on small viewport
  });
});

// tests/e2e/accessibility.spec.ts
test.describe("Accessibility", () => {
  test("keyboard navigation through sidebar", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.keyboard.press("Tab");
    // Verify focus moves through sidebar items
    const focused = await page.evaluate(() => document.activeElement?.textContent);
    expect(focused).toBeDefined();
  });

  test("command palette opens with Cmd+K and closes with Escape", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.keyboard.press("Meta+k");
    await expect(page.locator('[data-testid="command-palette"]')).toBeVisible();
    await page.keyboard.press("Escape");
    await expect(page.locator('[data-testid="command-palette"]')).not.toBeVisible();
  });

  test("all modals are keyboard-dismissible", async ({ page }) => {
    await signIn(page, testUsers.free);
    // Open export modal
    await navigateToExistingChat(page);
    await openExportModal(page);
    await page.keyboard.press("Escape");
    await expect(page.locator('[data-testid="export-modal"]')).not.toBeVisible();
  });
});
```

---

## 22.8 Performance Benchmarks 

Performance targets that must be met before launch. Measured via Playwright performance tracing and Vercel Analytics.

| Metric | Target | Measurement |
| :---- | :---- | :---- |
| First Contentful Paint (FCP) | \< 1.5s | Lighthouse on production build |
| Largest Contentful Paint (LCP) | \< 2.5s | Lighthouse on production build |
| Cumulative Layout Shift (CLS) | \< 0.1 | Lighthouse on production build |
| Time to Interactive (TTI) | \< 3.0s | Lighthouse on production build |
| Bundle size (initial JS) | \< 200KB gzipped | `next build` output analysis |
| Chat screen load (with data) | \< 1.0s | Playwright performance trace |
| Message send → first token | \< 500ms | Custom timing in cipher-route |
| Search results (20 results) | \< 2.0s | Edge Function response time |
| File upload (5MB) | \< 5.0s | Supabase storage upload timing |
| Dashboard load (with data) | \< 1.5s | Playwright performance trace |
| Sidebar navigation | \< 100ms | CSS transition time (measured, not variable) |
| Command palette open | \< 50ms | Instant — no data fetch required |
| Export generation (100 messages) | \< 10s | Edge Function response time |

### Performance Test Suite 

```ts
// tests/e2e/performance.spec.ts
test.describe("Performance", () => {
  test("dashboard loads within 1.5s", async ({ page }) => {
    await signIn(page, testUsers.free);
    const startTime = Date.now();
    await page.goto("/");
    await page.waitForSelector('[data-testid="stat-card"]');
    const loadTime = Date.now() - startTime;
    expect(loadTime).toBeLessThan(1500);
  });

  test("chat screen loads messages within 1s", async ({ page }) => {
    await signIn(page, testUsers.free);
    const startTime = Date.now();
    await page.goto("/chat");
    await page.click('[data-testid="chat-list-item"]');
    await page.waitForSelector('[data-testid="message"]');
    const loadTime = Date.now() - startTime;
    expect(loadTime).toBeLessThan(1000);
  });

  test("search returns results within 2s", async ({ page }) => {
    await signIn(page, testUsers.free);
    await page.goto("/search");
    const startTime = Date.now();
    await page.fill('[data-testid="search-input"]', "test");
    await page.keyboard.press("Enter");
    await page.waitForSelector('[data-testid="search-result"]');
    const searchTime = Date.now() - startTime;
    expect(searchTime).toBeLessThan(2000);
  });
});
```

---

## 22.9 CI/CD Pipeline 

### GitHub Actions Workflow 

```
# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  lint-and-typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm run lint
      - run: npm run type-check

  unit-and-integration:
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - uses: supabase/setup-cli@v1
        with:
          version: latest
      - run: npm ci
      - run: supabase start
      - run: npm run test:unit -- --coverage
      - run: npm run test:integration
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  component-tests:
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm run test:components

  e2e:
    runs-on: ubuntu-latest
    needs: [unit-and-integration, component-tests]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - uses: supabase/setup-cli@v1
        with:
          version: latest
      - run: npm ci
      - run: supabase start
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e -- --project=chromium
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
```

### Package.json Scripts 

```json
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "type-check": "tsc --noEmit",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:unit": "vitest run --config vitest.config.ts tests/unit",
    "test:integration": "vitest run --config vitest.config.ts tests/integration",
    "test:components": "vitest run --config vitest.config.ts tests/components",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:all": "npm run test:unit && npm run test:components && npm run test:integration && npm run test:e2e -- --project=chromium"
  }
}
```

### Quality Gates 

Pull requests are blocked from merging if any of these fail:

| Gate | Tool | Threshold |
| :---- | :---- | :---- |
| Lint clean | ESLint | Zero errors (warnings allowed) |
| Type-safe | TypeScript `tsc --noEmit` | Zero errors |
| Unit tests pass | Vitest | 100% pass rate |
| Integration tests pass | Vitest | 100% pass rate |
| Component tests pass | Vitest \+ RTL | 100% pass rate |
| Coverage | Vitest coverage | 70% statements, 60% branches |
| E2E critical flows pass | Playwright | 100% pass rate on Chromium |
| Bundle size | `next build` | \< 200KB gzipped initial JS |

---

## 22.10 Launch Readiness Checklist 

A release candidate must pass all items before deployment to production.

### Functional Readiness 

- [ ] All 14 E2E test suites pass on Chromium  
- [ ] Auth flow works for email signup, email login, Google OAuth, GitHub OAuth  
- [ ] Message send → AI response → store flow completes reliably for 50+ consecutive messages  
- [ ] Memory extraction creates correctly typed memories from conversations  
- [ ] All 4 tiers correctly gate their respective features  
- [ ] Stripe checkout flow completes and webhook updates tier  
- [ ] Export generates valid files in all 5 formats (by tier)  
- [ ] Search returns relevant results across all 6 content types  
- [ ] Browser proxy loads external pages and extract saves content  
- [ ] Team execution completes multi-step runs with correct dependency ordering  
- [ ] Settings changes persist across page refreshes  
- [ ] Realtime subscriptions deliver messages, typing indicators, and notifications

### Performance Readiness 

- [ ] LCP \< 2.5s on production build  
- [ ] FCP \< 1.5s on production build  
- [ ] CLS \< 0.1  
- [ ] Message TTFB \< 500ms (cipher-route to first SSE token)  
- [ ] Bundle size \< 200KB gzipped initial JS  
- [ ] No memory leaks in Realtime subscriptions (verified via 1-hour soak test)

### Security Readiness 

- [ ] RLS policies verified for all 35 tables (cross-user isolation confirmed)  
- [ ] API key encryption verified (encrypted\_key cannot be reversed without secret)  
- [ ] Stripe webhook signature verification active  
- [ ] CORS headers restrict access appropriately  
- [ ] No API keys or secrets in client-side bundle (verified via `next build` output grep)  
- [ ] Rate limiting functional and tested at all tier levels

### Accessibility Readiness 

- [ ] All interactive elements have keyboard focus indicators  
- [ ] Command palette navigable via keyboard  
- [ ] Modals trap focus and are Escape-dismissible  
- [ ] Color contrast meets WCAG 2.1 AA (4.5:1 for text, 3:1 for UI components)  
- [ ] Screen reader can navigate sidebar, message list, and forms

### Error Handling Readiness 

- [ ] All empty states render correctly (verified per Part 21 catalog)  
- [ ] Network loss shows offline banner and recovers gracefully  
- [ ] AI generation failures show inline errors with retry  
- [ ] Rate limit toast shows accurate countdown  
- [ ] Session expiration redirects to login with return URL  
- [ ] File upload failures show inline retry  
- [ ] Export failures show in-modal error with re-enabled button

---

## 22.11 Cross-Reference 

| Test Category | Covers Parts |
| :---- | :---- |
| Unit: tier-gates | Part 2 |
| Unit: formatting | Part 1 |
| Unit: Zustand stores | Part 20 |
| Unit: routing algorithm | Part 14 |
| Unit: token budgeting | Part 14 |
| Unit: memory extraction | Part 14, 15 |
| Unit: Edge Function logic | Part 19 |
| Integration: RLS | Part 18 |
| Integration: RPC functions | Part 18 |
| Integration: triggers | Part 18 |
| Integration: message pipeline | Parts 5, 14, 16, 17 |
| Integration: file upload | Part 8 |
| Component: all screens | Parts 4–13, 20 |
| Component: atoms/shared | Parts 1, 20, 21 |
| E2E: auth | Part 2 |
| E2E: chat | Part 5 |
| E2E: personas | Part 9 |
| E2E: instances | Part 7 |
| E2E: search | Part 6 |
| E2E: teams | Part 10 |
| E2E: browser | Part 11 |
| E2E: settings | Part 13 |
| E2E: tier gating | Part 2 |
| E2E: accessibility | Parts 3, 20 |
| Performance | All parts |

---

*End of Part 22\. Proceed to Part 23: Deployment & Infrastructure.*

# PART 23: DEPLOYMENT & INFRASTRUCTURE 

---

This part defines the complete deployment architecture for aiConnected: the three environments (development, staging, production), all infrastructure components, the CI/CD pipeline from commit to production, the database migration workflow, Edge Function deployment, environment variable management, monitoring and observability, security hardening, performance optimization, backup strategy, and incident response procedures.

---

## 23.1 Architecture Overview 

aiConnected runs on two managed platforms with no custom servers to operate:

```
┌──────────────────────────────────────────────────────────────┐
│                         VERCEL                                │
│  ┌─────────────────┐  ┌──────────────┐  ┌────────────────┐  │
│  │ Next.js App      │  │ Edge Network  │  │ Preview        │  │
│  │ (SSR + Static)   │  │ (CDN + Edge)  │  │ Deployments    │  │
│  └────────┬─────────┘  └──────────────┘  └────────────────┘  │
│           │                                                    │
└───────────┼────────────────────────────────────────────────────┘
            │  HTTPS (Supabase JS SDK + Edge Function invocations)
            ▼
┌──────────────────────────────────────────────────────────────┐
│                        SUPABASE                               │
│  ┌──────────┐ ┌───────────┐ ┌──────────┐ ┌───────────────┐  │
│  │ Postgres  │ │ Auth      │ │ Realtime │ │ Edge Functions │  │
│  │ (+ RLS)   │ │ (GoTrue)  │ │ (WS)    │ │ (16 Deno)     │  │
│  ├──────────┤ ├───────────┤ ├──────────┤ ├───────────────┤  │
│  │ pgvector  │ │ Email     │ │ Postgres │ │ cipher-route  │  │
│  │ pg_cron   │ │ Google    │ │ Changes  │ │ cipher-memory │  │
│  │ pg_trgm   │ │ GitHub    │ │ Broadcast│ │ search        │  │
│  ├──────────┤ └───────────┘ │ Presence │ │ analytics     │  │
│  │ Storage   │               └──────────┘ │ + 12 more     │  │
│  │ (4 buckets│                             └───────────────┘  │
│  └──────────┘                                                  │
│                                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │ External: OpenRouter (AI models) │ Stripe (billing)      │  │
│  └──────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘
```

**Design principle:** Zero custom infrastructure. Both Vercel and Supabase are managed platforms with built-in scaling, redundancy, and monitoring. The only external dependencies are OpenRouter (AI model gateway) and Stripe (billing). This minimizes operational burden and lets the development focus stay on the product.

---

## 23.2 Environments 

### Development (Local) 

Developers run the full stack locally using the Supabase CLI and Next.js dev server.

```shell
# Terminal 1: Start local Supabase (Postgres, Auth, Realtime, Edge Functions, Storage)
supabase start

# Terminal 2: Start Next.js dev server
npm run dev
```

**Local Supabase services:**

| Service | Local Port | Purpose |
| :---- | :---- | :---- |
| API (PostgREST) | 54321 | REST API for all tables |
| Auth (GoTrue) | 54321 | Authentication endpoints |
| Realtime | 54321 | WebSocket subscriptions |
| Storage | 54321 | File upload/download |
| Edge Functions | 54321 | Deno function runtime |
| Database (Postgres) | 54322 | Direct database access |
| Studio | 54323 | Database admin GUI |
| Inbucket | 54324 | Email inbox for testing |

**Local environment file:**

```
# .env.local (git-ignored)
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=[local-anon-key]        # Output by supabase start
SUPABASE_SERVICE_ROLE_KEY=[local-service-role-key]      # Output by supabase start
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_test_...
OPENROUTER_API_KEY=sk-or-...
API_KEY_ENCRYPTION_SECRET=[32-byte-hex-for-dev]
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```

**Seed data:**

```shell
# Apply seed data for development
supabase db reset    # Drops, recreates, runs migrations, then runs seed.sql
```

`supabase/seed.sql` creates:

- 1 test user (via `auth.users` insert \+ profile)  
- 3 persona templates (builtin)  
- 5 instance types (builtin)  
- The full model\_registry (all supported models)  
- 2 sample personas with skills and memories  
- 1 sample instance with chats and messages  
- Sample search history entries

### Staging 

A dedicated Supabase project and Vercel preview environment for pre-production testing.

| Component | Platform | Trigger |
| :---- | :---- | :---- |
| Frontend | Vercel Preview | Auto-deploy on every PR push |
| Database | Supabase (staging project) | Manual migration via CLI |
| Edge Functions | Supabase (staging project) | Manual deploy via CLI |

**Staging Supabase project:**

```shell
# Link to staging project
supabase link --project-ref [staging-project-ref]

# Apply pending migrations
supabase db push

# Deploy Edge Functions
supabase functions deploy

# Set secrets
supabase secrets set OPENROUTER_API_KEY=sk-or-staging-...
supabase secrets set STRIPE_SECRET_KEY=sk_test_staging_...
# ... (all secrets from Section 23.5)
```

**Staging environment variables (Vercel):**

Set in the Vercel Dashboard under the staging/preview environment scope:

```
NEXT_PUBLIC_SUPABASE_URL=https://[staging-ref].supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=[staging-anon-key]
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_SITE_URL=https://aiconnected-staging.vercel.app
```

**Staging policies:**

- Uses Stripe test mode (no real charges)  
- Uses real OpenRouter API key (test with small models to minimize cost)  
- Contains synthetic test data, not real user data  
- Accessible to team members only (Vercel password protection)

### Production 

| Component | Platform | URL |
| :---- | :---- | :---- |
| Frontend | Vercel Production | [https://aiconnected.app](https://aiconnected.app) |
| Database | Supabase (production project) | https://\[prod-ref\].supabase.co |
| Edge Functions | Supabase (production project) | Same as database URL |

**Production deployment trigger:** Merge to `main` branch → Vercel auto-deploys.

**Production database changes:** Never auto-deployed. Always manual via CLI with review:

```shell
# Link to production
supabase link --project-ref [prod-project-ref]

# Review pending migrations
supabase db diff

# Apply migrations
supabase db push

# Deploy updated Edge Functions
supabase functions deploy
```

---

## 23.3 Database Migration Workflow 

### Migration File Structure 

```
supabase/
  migrations/
    20240101000000_extensions.sql           — pgvector, pg_cron, pg_trgm
    20240101000001_utility_functions.sql     — handle_updated_at, handle_new_user
    20240101000002_profiles.sql             — profiles table + trigger
    20240101000003_persona_templates.sql    — persona_templates + seed data
    20240101000004_model_registry.sql       — model_registry + seed data
    20240101000005_subscriptions.sql        — subscriptions, api_keys, credit_transactions
    20240101000006_instances.sql            — instances, instance_types
    20240101000007_personas.sql             — personas, persona_skills, persona_boundaries
    20240101000008_chats.sql               — chats, chat_participants, messages
    20240101000009_cognigraph.sql           — persona_memories, memory_edges, memory_checkpoints
    20240101000010_teams.sql               — teams, team_members, team_tasks, team_runs
    20240101000011_files.sql               — files, workspace_items, export_history
    20240101000012_browser.sql             — browser_sessions, browser_tabs, browser_history, browser_extracts
    20240101000013_search.sql              — search_history, saved_results
    20240101000014_system.sql              — notifications, activity_log, instruction_memory
    20240101000015_model_routing.sql        — token_usage, rate_limit_counters, model_role_assignments
    20240101000016_rpc_functions.sql        — All 16 RPC functions
    20240101000017_rls_policies.sql         — RLS policies for all tables
    20240101000018_storage_buckets.sql      — 4 storage buckets + policies
    20240101000019_triggers.sql             — Seed triggers (model_roles, instance_types)
    20240101000020_pg_cron_jobs.sql         — 4 scheduled jobs
    20240101000021_indexes.sql              — Full-text search indexes, GIN indexes, covering indexes
  seed.sql                                  — Development seed data
```

### Migration Commands 

```shell
# Create a new migration
supabase migration new [descriptive_name]
# → Creates supabase/migrations/[timestamp]_[descriptive_name].sql

# Apply migrations to local database
supabase db reset       # Full reset: drop + recreate + migrate + seed

# Apply to remote (staging or production)
supabase db push        # Applies pending migrations only

# Check migration status
supabase migration list # Shows applied vs pending

# Generate diff from local changes
supabase db diff --use-migra [migration_name]
# → Creates migration SQL from schema differences
```

### Migration Rules 

1. **Forward-only:** Migrations are append-only. Never edit a migration that has been applied to staging or production.  
2. **Idempotent where possible:** Use `CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE FUNCTION`, `CREATE INDEX IF NOT EXISTS`.  
3. **Backward-compatible:** New columns should have `DEFAULT` values or be nullable. Column drops require a 2-step process: (1) stop reading the column, (2) drop it in a later migration.  
4. **Test locally first:** Every migration runs against `supabase db reset` locally before being pushed to staging.  
5. **Review before production:** Migrations to production are reviewed by reading the SQL diff and confirmed manually.

### Schema Change Process 

```
Developer creates migration locally
     ↓
supabase db reset (verify locally)
     ↓
Commit migration file to feature branch
     ↓
PR review includes migration SQL review
     ↓
Merge to main → staging deploy
     ↓
supabase db push --linked (staging)
     ↓
Verify staging (manual QA)
     ↓
supabase db push --linked (production)
```

---

## 23.4 Edge Function Deployment 

### Deployment Process 

Edge Functions are deployed independently from the Next.js frontend. They run in Supabase's Deno runtime.

```shell
# Deploy all functions at once
supabase functions deploy

# Deploy a single function (faster, used during development)
supabase functions deploy cipher-route
supabase functions deploy cipher-memory
supabase functions deploy search
# ... etc.
```

### Function Directory Structure (Part 19, Section 19.20) 

```
supabase/functions/
  _shared/
    cors.ts
    auth.ts
    errors.ts
    crypto.ts
    models.ts
    tokens.ts
  cipher-route/index.ts
  cipher-memory/index.ts
  cipher-cleanup/index.ts
  cipher-health/index.ts
  search/index.ts
  analytics/index.ts
  chat-export/index.ts
  browser-proxy/index.ts
  browser-extract/index.ts
  team-execute/index.ts
  validate-api-key/index.ts
  store-api-key/index.ts
  stripe-webhook/index.ts
  create-checkout-session/index.ts
  files-zip/index.ts
  generate-embedding/index.ts
```

### Function Configuration 

Each function can have specific resource limits set in `supabase/config.toml`:

```
[functions.cipher-route]
verify_jwt = true

[functions.cipher-memory]
verify_jwt = false  # Service role auth, not JWT

[functions.cipher-cleanup]
verify_jwt = false

[functions.cipher-health]
verify_jwt = false

[functions.stripe-webhook]
verify_jwt = false  # Stripe signature verification instead

[functions.team-execute]
verify_jwt = false  # Service role auth
```

### Deployment Verification 

After deploying, verify each function is reachable:

```shell
# List deployed functions
supabase functions list

# Check function logs
supabase functions logs cipher-route --tail

# Test a function endpoint
curl -X POST https://[project-ref].supabase.co/functions/v1/search \
  -H "Authorization: Bearer [user-jwt]" \
  -H "Content-Type: application/json" \
  -d '{"query":"test","limit":5}'
```

---

## 23.5 Environment Variable Management 

### Complete Variable Registry 

All environment variables across both platforms, organized by service:

**Supabase Edge Functions** (set via `supabase secrets set`):

| Variable | Description | Example |
| :---- | :---- | :---- |
| `SUPABASE_URL` | Auto-provided by Supabase | `https://[ref].supabase.co` |
| `SUPABASE_ANON_KEY` | Auto-provided by Supabase | `eyJhbG...` |
| `SUPABASE_SERVICE_ROLE_KEY` | Auto-provided by Supabase | `eyJhbG...` |
| `OPENROUTER_API_KEY` | Platform AI model API key | `sk-or-v1-...` |
| `STRIPE_SECRET_KEY` | Stripe server-side key | `sk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | `whsec_...` |
| `STRIPE_PRICE_PLUS` | Stripe price ID for Plus tier | `price_...` |
| `STRIPE_PRICE_PREMIUM` | Stripe price ID for Premium tier | `price_...` |
| `STRIPE_PRICE_PRO` | Stripe price ID for Pro tier | `price_...` |
| `STRIPE_PRICE_CREDITS_100` | Stripe price ID for 100 credits | `price_...` |
| `STRIPE_PRICE_CREDITS_300` | Stripe price ID for 300 credits | `price_...` |
| `STRIPE_PRICE_CREDITS_1000` | Stripe price ID for 1000 credits | `price_...` |
| `API_KEY_ENCRYPTION_SECRET` | 32-byte hex for AES-GCM encryption | `a1b2c3d4...` |
| `SITE_URL` | Public application URL | `https://aiconnected.app` |

**Vercel** (set in Dashboard or `vercel env`):

| Variable | Scope | Description |
| :---- | :---- | :---- |
| `NEXT_PUBLIC_SUPABASE_URL` | Client \+ Server | Supabase project URL |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Client \+ Server | Supabase anonymous key |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Client | Stripe publishable key |
| `NEXT_PUBLIC_SITE_URL` | Client | Application base URL |
| `SUPABASE_SERVICE_ROLE_KEY` | Server only | For server-side Supabase operations |
| `SENTRY_DSN` | Client \+ Server | Sentry error tracking |
| `SENTRY_AUTH_TOKEN` | Server only | Sentry release upload |

### Secret Rotation 

| Secret | Rotation Schedule | Process |
| :---- | :---- | :---- |
| `SUPABASE_SERVICE_ROLE_KEY` | On compromise only | Regenerate in Supabase Dashboard → update all references |
| `OPENROUTER_API_KEY` | Quarterly | Generate new key → set in all environments → revoke old key |
| `STRIPE_SECRET_KEY` | On compromise only | Roll key in Stripe Dashboard → update all environments |
| `API_KEY_ENCRYPTION_SECRET` | Never (re-encryption required) | Generate new secret → run re-encryption migration → update |
| `SENTRY_AUTH_TOKEN` | Annually | Regenerate in Sentry → update Vercel env |

### Environment Parity 

All three environments (dev, staging, production) must have the same variables set. The only differences:

| Variable | Dev | Staging | Production |
| :---- | :---- | :---- | :---- |
| `SUPABASE_URL` | `localhost:54321` | `[staging-ref].supabase.co` | `[prod-ref].supabase.co` |
| `STRIPE_SECRET_KEY` | `sk_test_dev_...` | `sk_test_staging_...` | `sk_live_...` |
| `SITE_URL` | `localhost:3000` | `staging.vercel.app` | `aiconnected.app` |

---

## 23.6 CI/CD Pipeline 

### Branch Strategy 

```
main                — Production. Protected branch. Requires PR + CI pass.
  └── feature/*     — Feature branches. PR to main.
  └── fix/*         — Bug fix branches. PR to main.
  └── release/*     — Release prep branches (rare). PR to main.
```

### Pipeline Stages 

```
Push to feature branch
     ↓
GitHub Actions: CI Pipeline
  ├── Job 1: Lint + Type Check (60s)
  │   ├── ESLint (zero errors)
  │   └── tsc --noEmit (zero errors)
  │
  ├── Job 2: Unit + Integration Tests (3–5 min)
  │   ├── supabase start (local DB)
  │   ├── Vitest: unit tests
  │   ├── Vitest: integration tests
  │   └── Coverage report upload
  │
  ├── Job 3: Component Tests (2–3 min)
  │   └── Vitest: component tests with RTL
  │
  └── Job 4: E2E Tests (5–10 min, runs after Jobs 2+3)
      ├── supabase start (local DB)
      ├── npm run build + npm start
      ├── Playwright: Chromium tests
      └── Artifact upload (screenshots on failure)
     ↓
All pass → PR is mergeable
     ↓
Merge to main
     ↓
Vercel auto-deploys to production (frontend)
     ↓
Manual: supabase db push + functions deploy (database + Edge Functions)
```

### GitHub Actions Workflow 

Full workflow defined in Part 22, Section 22.9. Key additions for deployment:

```
# .github/workflows/deploy-supabase.yml
# Manual workflow for deploying database migrations and Edge Functions
name: Deploy Supabase

on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        type: choice
        options:
          - staging
          - production
      deploy_migrations:
        description: "Apply database migrations"
        type: boolean
        default: true
      deploy_functions:
        description: "Deploy Edge Functions"
        type: boolean
        default: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - uses: supabase/setup-cli@v1
        with:
          version: latest

      - name: Link Supabase project
        run: supabase link --project-ref ${{ secrets.SUPABASE_PROJECT_REF }}
        env:
          SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}

      - name: Apply migrations
        if: ${{ inputs.deploy_migrations }}
        run: supabase db push
        env:
          SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}

      - name: Deploy Edge Functions
        if: ${{ inputs.deploy_functions }}
        run: supabase functions deploy
        env:
          SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
```

### Vercel Configuration 

```json
// vercel.json
{
  "framework": "nextjs",
  "buildCommand": "npm run build",
  "outputDirectory": ".next",
  "regions": ["iad1"],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "X-XSS-Protection", "value": "1; mode=block" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
      ]
    }
  ],
  "redirects": [
    { "source": "/app", "destination": "/", "permanent": true }
  ]
}
```

### Vercel Deployment Settings 

| Setting | Value |
| :---- | :---- |
| Framework Preset | Next.js |
| Build Command | `npm run build` |
| Output Directory | `.next` |
| Node.js Version | 20.x |
| Root Directory | `.` |
| Install Command | `npm ci` |
| Production Branch | `main` |
| Preview Branches | All other branches |
| Auto-cancel redundant builds | Enabled |
| Skew Protection | Enabled |

---

## 23.7 Monitoring & Observability 

### Frontend Monitoring 

**Vercel Analytics (built-in):**

Enabled in `next.config.ts`:

```ts
// next.config.ts
const nextConfig = {
  experimental: {
    // Enable Web Vitals reporting
  },
};
```

Tracks: FCP, LCP, CLS, FID, TTFB, INP. Accessible in Vercel Dashboard → Analytics tab.

**Sentry (error tracking):**

```shell
npm install @sentry/nextjs
npx @sentry/wizard@latest -i nextjs
```

```ts
// sentry.client.config.ts
import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1,           // 10% of requests traced
  replaysSessionSampleRate: 0.01,  // 1% of sessions recorded
  replaysOnErrorSampleRate: 0.5,   // 50% of error sessions recorded
});
```

**What Sentry captures:**

- Unhandled JavaScript exceptions  
- React error boundaries  
- Failed API calls (Supabase SDK errors, Edge Function failures)  
- Performance traces (route transitions, data fetching)  
- Session replays for error reproduction

### Backend Monitoring 

**Supabase Dashboard (built-in):**

Accessible at `https://supabase.com/dashboard/project/[ref]`. Monitors:

| Metric | Location | Alert Threshold |
| :---- | :---- | :---- |
| Database CPU utilization | Infrastructure → Database | \> 80% sustained |
| Database disk usage | Infrastructure → Database | \> 80% of quota |
| Active connections | Infrastructure → Database | \> 80% of pool |
| Auth request rate | Authentication → Usage | Unusual spike |
| Realtime connections | Realtime → Usage | \> 80% of limit |
| Edge Function invocations | Edge Functions → Logs | Error rate \> 5% |
| Storage usage | Storage → Usage | \> 90% of plan |

**Edge Function Logging:**

Every Edge Function logs structured JSON to the Supabase Functions log stream. Key log events:

```ts
// Structured logging in Edge Functions
function log(level: "info" | "warn" | "error", event: string, data: Record<string, any>) {
  console.log(JSON.stringify({
    level,
    event,
    timestamp: new Date().toISOString(),
    ...data,
  }));
}

// Usage in cipher-route
log("info", "cipher_route_start", {
  user_id: user.id,
  chat_id: body.chat_id,
  has_directed_persona: !!body.directed_persona_id,
});

log("info", "cipher_route_complete", {
  user_id: user.id,
  chat_id: body.chat_id,
  model_used: selectedModel,
  routing_reason: routingResult.reason,
  latency_ms: Date.now() - startTime,
  prompt_tokens: usage.prompt_tokens,
  completion_tokens: usage.completion_tokens,
  fallback_used: !!fallbackNote,
});

log("error", "cipher_route_error", {
  user_id: user.id,
  chat_id: body.chat_id,
  error_message: error.message,
  error_code: error.status,
  model_attempted: selectedModel,
});
```

**Key log events across functions:**

| Function | Event | Data |
| :---- | :---- | :---- |
| `cipher-route` | `cipher_route_start` | user\_id, chat\_id, directed\_persona |
| `cipher-route` | `cipher_route_complete` | model, routing\_reason, latency, tokens, fallback |
| `cipher-route` | `cipher_route_error` | error\_message, error\_code, model\_attempted |
| `cipher-memory` | `memory_extraction_complete` | memories\_created, instructions\_found, duration |
| `cipher-cleanup` | `cleanup_complete` | users\_processed, stale\_flagged, expired\_skills, purged\_chats |
| `cipher-health` | `health_snapshot_complete` | personas\_processed, avg\_stability, drift\_distribution |
| `search` | `search_complete` | query, results\_count, execution\_time\_ms, backends\_failed |
| `stripe-webhook` | `stripe_event_processed` | event\_type, customer\_id, tier\_change |
| `team-execute` | `team_run_complete` | team\_id, steps\_completed, steps\_failed, total\_duration |

### Custom Metrics (activity\_log table) 

The `activity_log` table (Part 18\) serves as a persistent audit trail. Key metrics extracted from it:

| Metric | Query | Purpose |
| :---- | :---- | :---- |
| Daily active users | `COUNT(DISTINCT user_id) WHERE created_at > now() - '24h'` | Usage tracking |
| Messages per day | `COUNT(*) WHERE activity_type = 'message_sent'` | Growth tracking |
| Model usage distribution | `COUNT(*) GROUP BY metadata->>'model_used'` | Cost analysis |
| Error rate | `COUNT(*) WHERE activity_type = 'error' / total` | Reliability |
| Average response latency | `AVG((metadata->>'latency_ms')::int)` | Performance |

---

## 23.8 Security Hardening 

### Transport Security 

- All traffic is HTTPS only. Vercel enforces HTTPS with automatic certificate management.  
- Supabase connections use TLS. The Supabase JS SDK connects over HTTPS.  
- WebSocket (Realtime) connections use WSS.

### Authentication Security 

| Measure | Implementation |
| :---- | :---- |
| Password hashing | bcrypt (handled by Supabase Auth / GoTrue) |
| JWT expiry | Access token: 1 hour. Refresh token: 7 days. |
| Session management | Supabase Auth `onAuthStateChange` with automatic token refresh |
| OAuth providers | Google, GitHub (configured in Supabase Dashboard) |
| Email confirmation | Required for email signup (Supabase Auth setting) |
| Rate limiting on auth | Supabase built-in: 30 requests per hour per IP |

### Data Security 

| Measure | Implementation |
| :---- | :---- |
| Row Level Security | Enabled on all 35 tables (Part 18). Verified in integration tests. |
| API key encryption | AES-256-GCM with platform-managed secret (Part 19, Section 19.14) |
| Service role isolation | Service role key never exposed to client. Only used in Edge Functions. |
| Storage access control | Bucket-level RLS policies. Users can only access their own files. |
| Input sanitization | `sanitizeUserInput()` in cipher-route (Part 14). `escapeHtml()` in exports. |
| SQL injection prevention | Supabase SDK uses parameterized queries. No raw SQL from user input. |

### HTTP Security Headers 

Set in `vercel.json` (Section 23.6):

| Header | Value | Purpose |
| :---- | :---- | :---- |
| `X-Content-Type-Options` | `nosniff` | Prevent MIME sniffing |
| `X-Frame-Options` | `DENY` | Prevent clickjacking |
| `X-XSS-Protection` | `1; mode=block` | Enable XSS filter |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Limit referrer leakage |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Disable unnecessary browser APIs |

### Content Security Policy 

Added via Next.js middleware for more granular control:

```ts
// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  const csp = [
    "default-src 'self'",
    `script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com`,
    `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
    `font-src 'self' https://fonts.gstatic.com`,
    `img-src 'self' data: blob: https://*.supabase.co`,
    `connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com https://openrouter.ai`,
    `frame-src 'self' https://js.stripe.com https://*.supabase.co`,
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
  ].join("; ");

  response.headers.set("Content-Security-Policy", csp);
  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
```

### Browser Workspace Security 

The browser proxy (Part 11\) has specific security measures:

| Risk | Mitigation |
| :---- | :---- |
| XSS via proxied content | `sandbox` attribute on iframe: `allow-scripts allow-same-origin allow-forms allow-popups` |
| Internal network access | URL blocklist: `localhost`, `127.0.0.1`, `0.0.0.0`, `metadata.google.internal`, `10.*`, `172.16–31.*`, `192.168.*` |
| Cookie theft | Proxy strips all cookies from proxied responses |
| Clickjacking | Proxy strips `X-Frame-Options` and `CSP` from proxied content (necessary for iframe display) |
| postMessage injection | Origin checking on all postMessage handlers |

### Dependency Security 

```shell
# Run dependency audit
npm audit

# Auto-fix where possible
npm audit fix

# Check for known vulnerabilities
npx audit-ci --config audit-ci.json
```

Add to CI pipeline:

```
- name: Security audit
  run: npm audit --audit-level=high
  continue-on-error: true  # Warn but don't block
```

---

## 23.9 Performance Optimization 

### Frontend Optimizations 

**Code splitting:** Next.js App Router automatically code-splits by route. Each page loads only its required JavaScript.

**Font optimization:** DM Sans loaded via `next/font/google` with subset preloading:

```ts
const dmSans = DM_Sans({
  subsets: ["latin"],
  weight: ["200", "300", "400", "500", "600", "700"],
  variable: "--font-dm-sans",
  display: "swap",     // Show fallback font immediately, swap when loaded
  preload: true,
});
```

**Image optimization:** No user-uploaded images rendered directly. All images serve through Supabase Storage signed URLs. Avatars are initial-based (no images in v1).

**Bundle analysis:**

```shell
# Analyze bundle composition
ANALYZE=true npm run build
# Opens webpack-bundle-analyzer in browser
```

Add to `next.config.ts`:

```ts
const nextConfig = {
  webpack: (config, { isServer }) => {
    if (process.env.ANALYZE) {
      const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");
      config.plugins.push(
        new BundleAnalyzerPlugin({
          analyzeMode: "static",
          reportFilename: isServer ? "../analyze/server.html" : "./analyze/client.html",
          openAnalyzer: true,
        })
      );
    }
    return config;
  },
};
```

**TanStack Query caching strategy** (consolidated from all parts):

| Data Type | staleTime | gcTime | Refetch Strategy |
| :---- | :---- | :---- | :---- |
| Profile | 30s | 5 min | refetchOnWindowFocus |
| Chat list | 30s | 5 min | refetchOnWindowFocus \+ Realtime invalidation |
| Messages | 30s | 5 min | Realtime insert handler |
| Personas | 30s | 5 min | refetchOnWindowFocus |
| Instances | 30s | 5 min | refetchOnWindowFocus |
| Files | 30s | 5 min | refetchOnWindowFocus |
| Teams | 30s | 5 min | refetchOnWindowFocus |
| Search results | 60s | 5 min | Refetch on param change |
| Analytics | 5 min | 10 min | Manual refetch (refresh button) |
| Model registry | 1 hour | 2 hours | Rare changes |
| Signed URLs | 50 min | 55 min | Pre-expiry refresh |
| Dashboard stats | 30s | 5 min | refetchOnWindowFocus \+ Realtime |
| Activity feed | 15s | 5 min | Realtime insert handler |

### Database Optimizations 

**Indexes** (Part 18, Section 18.16):

All indexes are created in the migration file `20240101000021_indexes.sql`. Key indexes:

| Table | Index | Type | Purpose |
| :---- | :---- | :---- | :---- |
| `chats` | `idx_chats_user_updated` | B-tree (user\_id, updated\_at DESC) | Chat list sorting |
| `messages` | `idx_messages_chat_created` | B-tree (chat\_id, created\_at) | Message loading |
| `messages` | `idx_messages_content_fts` | GIN (to\_tsvector content) | Full-text search |
| `chats` | `idx_chats_title_fts` | GIN (to\_tsvector title) | Title search |
| `files` | `idx_files_name_fts` | GIN (to\_tsvector name) | File search |
| `persona_memories` | `idx_memories_embedding` | IVFFlat (embedding vector) | Semantic search |
| `persona_memories` | `idx_memories_persona_active` | B-tree (persona\_id, active) | Memory retrieval |
| `activity_log` | `idx_activity_user_created` | B-tree (user\_id, created\_at DESC) | Activity feed |
| `token_usage` | `idx_token_user_created` | B-tree (user\_id, created\_at) | Usage analytics |

**Materialized view refresh:**

```sql
-- analytics_daily_summary refreshes daily at 03:00 UTC via pg_cron
SELECT cron.schedule('refresh-analytics-daily', '0 3 * * *',
  'REFRESH MATERIALIZED VIEW CONCURRENTLY analytics_daily_summary');
```

**Connection pooling:** Supabase manages connection pooling via PgBouncer (Supavisor). Default pool mode is `transaction`. No application-level pooling configuration required.

### Edge Function Optimizations 

| Optimization | Implementation |
| :---- | :---- |
| Cold start reduction | Shared `_shared/` modules keep imports minimal per function |
| Parallel execution | `Promise.allSettled` in cipher-memory (7 parallel tasks), search (6 parallel backends), analytics (multiple RPC calls) |
| Fire-and-forget | cipher-memory invoked without `await` from cipher-route |
| Early response | cipher-route begins SSE stream before memory extraction completes |
| Token estimation | Client-side token estimation avoids unnecessary model calls for context-exceeded errors |

---

## 23.10 Backup & Recovery 

### Database Backups 

**Supabase managed backups:**

| Plan | Backup Frequency | Retention | Point-in-Time Recovery |
| :---- | :---- | :---- | :---- |
| Free | Daily | 7 days | No |
| Pro | Daily | 7 days | Yes (up to 7 days) |
| Team | Daily \+ on-demand | 14 days | Yes (up to 14 days) |
| Enterprise | Continuous | 30 days | Yes (up to 30 days) |

**Recommendation:** Use Supabase Pro plan (or higher) for production to get Point-in-Time Recovery (PITR).

**Manual backup:**

```shell
# Export database dump (run from machine with access)
pg_dump -h db.[ref].supabase.co -U postgres -d postgres > backup_$(date +%Y%m%d).sql

# Export specific tables
pg_dump -h db.[ref].supabase.co -U postgres -d postgres -t profiles -t chats -t messages > partial_backup.sql
```

### Storage Backups 

Supabase Storage files are stored in the same project infrastructure. For additional redundancy:

```shell
# Sync storage bucket to local backup
npx supabase storage ls user-files --recursive > file_manifest.txt
# Download critical files using signed URLs or the Storage API
```

### Recovery Procedures 

| Scenario | Recovery Method | RTO | RPO |
| :---- | :---- | :---- | :---- |
| Accidental table drop | PITR to before the drop | 30 min | \< 5 min |
| Corrupted migration | Restore from backup \+ re-apply clean migration | 1 hour | \< 24 hours |
| Edge Function bug | Redeploy previous version from git history | 5 min | 0 (stateless) |
| Frontend bug | Revert Vercel deployment (instant rollback) | 1 min | 0 |
| Storage file deletion | Restore from backup or Supabase support | 1–24 hours | Up to 24 hours |
| Full Supabase outage | Wait for platform recovery (managed service) | Depends on Supabase | Depends on Supabase |

### Vercel Rollback 

Vercel maintains all previous deployments. Instant rollback:

```shell
# Via Vercel CLI
vercel rollback [deployment-url]

# Via Dashboard
# Vercel Dashboard → Deployments → Select previous → Promote to Production
```

---

## 23.11 Scaling Considerations 

aiConnected is designed for the initial launch scale (hundreds to low thousands of users). The architecture is horizontally scalable through platform features.

### Scaling Levers 

| Bottleneck | Scaling Lever | Trigger |
| :---- | :---- | :---- |
| Frontend traffic | Vercel Edge Network auto-scales | Automatic |
| Database CPU | Supabase compute addon (upgrade plan) | CPU \> 70% sustained |
| Database connections | Supabase pooler (PgBouncer) already active | Connections \> 80% |
| Database storage | Supabase disk auto-scales (Pro plan) | Automatic |
| Realtime connections | Supabase Realtime limits per plan | Upgrade plan |
| Edge Function concurrency | Supabase auto-scales per plan | Automatic |
| AI model throughput | OpenRouter handles scaling | N/A |
| Stripe webhook volume | Stripe handles delivery | N/A |

### When to Consider Additional Infrastructure 

| Signal | Action | Timeline |
| :---- | :---- | :---- |
| \> 5,000 DAU | Add read replicas for analytics queries | Weeks 20+ |
| \> 50,000 stored memories | Tune pgvector index (change IVFFlat lists parameter) | As needed |
| \> 100,000 files in storage | Consider CDN layer for signed URLs | As needed |
| Edge Function timeout issues | Split long-running functions (team-execute) into smaller steps | As needed |
| Consistent \>3s API response time | Add database query caching (Redis) or move hot data to materialized views | As needed |

---

## 23.12 Domain & DNS Configuration 

### Domain Setup 

| Domain | Purpose | DNS Provider |
| :---- | :---- | :---- |
| `aiconnected.app` | Production application | Registrar DNS or Cloudflare |
| `staging.aiconnected.app` | Staging environment (optional) | Same |

### DNS Records 

```
Type    Name                  Value                           TTL
A       aiconnected.app       76.76.21.21 (Vercel)           300
CNAME   www                   cname.vercel-dns.com            300
```

### Vercel Domain Configuration 

```shell
# Add custom domain
vercel domains add aiconnected.app

# Configure in Vercel Dashboard:
# Settings → Domains → Add → aiconnected.app
# → Follow DNS verification steps
# → Enable automatic HTTPS (Let's Encrypt)
```

### Supabase Custom Domain (Optional) 

For white-labeling the Supabase URL (e.g., `api.aiconnected.app` instead of `[ref].supabase.co`):

```shell
# In Supabase Dashboard → Settings → Custom Domains
# Add: api.aiconnected.app
# Add CNAME: api → [ref].supabase.co
```

---

## 23.13 Stripe Configuration 

### Stripe Products & Prices 

Create in Stripe Dashboard (or via Stripe CLI):

| Product | Price ID (env var) | Amount | Interval |
| :---- | :---- | :---- | :---- |
| aiConnected Plus | `STRIPE_PRICE_PLUS` | $15/month | Monthly |
| aiConnected Premium | `STRIPE_PRICE_PREMIUM` | $39/month | Monthly |
| aiConnected Pro | `STRIPE_PRICE_PRO` | $100/month | Monthly |
| 100 Credits Pack | `STRIPE_PRICE_CREDITS_100` | $5 (one-time) | — |
| 300 Credits Pack | `STRIPE_PRICE_CREDITS_300` | $12 (one-time) | — |
| 1000 Credits Pack | `STRIPE_PRICE_CREDITS_1000` | $35 (one-time) | — |

### Webhook Configuration 

In Stripe Dashboard → Developers → Webhooks:

| Setting | Value |
| :---- | :---- |
| Endpoint URL | `https://[prod-ref].supabase.co/functions/v1/stripe-webhook` |
| Events | `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`, `invoice.paid` |
| Signing secret | Stored as `STRIPE_WEBHOOK_SECRET` |

### Customer Portal 

Configure Stripe Customer Portal for self-service subscription management:

| Setting | Value |
| :---- | :---- |
| Enable customer portal | Yes |
| Allow plan changes | Yes (upgrade and downgrade) |
| Allow cancellation | Yes (at period end) |
| Allow payment method update | Yes |
| Redirect URL | `https://aiconnected.app/settings?tab=general` |

---

## 23.14 Incident Response 

### Severity Levels 

| Severity | Definition | Response Time | Examples |
| :---- | :---- | :---- | :---- |
| P1 (Critical) | Service completely unavailable | 15 minutes | Auth broken, database down, all AI calls failing |
| P2 (High) | Major feature broken for all users | 1 hour | Chat streaming broken, file uploads failing, Realtime down |
| P3 (Medium) | Feature degraded or broken for some users | 4 hours | Search returning incomplete results, one model provider failing |
| P4 (Low) | Minor issue, workaround available | 24 hours | UI glitch, non-critical toast not appearing, analytics stale |

### Response Procedures 

**P1 — Critical:**

1. Identify the failing component (Vercel, Supabase, OpenRouter, Stripe)  
2. If frontend: rollback Vercel deployment  
3. If Edge Function: redeploy previous version from git  
4. If database: check Supabase status page, open support ticket  
5. If OpenRouter: AI features degrade gracefully (error messages shown)  
6. Communicate status via status page or social media

**P2 — High:**

1. Identify root cause via Sentry errors \+ Edge Function logs  
2. Hotfix: create `fix/*` branch → fast PR → merge → deploy  
3. If database migration caused it: evaluate PITR restore vs forward-fix

**P3/P4 — Medium/Low:**

1. Create GitHub issue with reproduction steps  
2. Schedule fix in next sprint  
3. Deploy with regular release cycle

### Status Page 

Use a lightweight status page service (e.g., Instatus, Upptime, or Betteruptime) to communicate:

- Current system status (Operational / Degraded / Outage)  
- Incident history  
- Planned maintenance windows

### Health Check Endpoints 

```ts
// app/api/health/route.ts (Next.js server route)
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";

export async function GET() {
  const checks: Record<string, boolean> = {};

  // Database connectivity
  try {
    const supabase = createClient();
    const { error } = await supabase.from("model_registry").select("id").limit(1);
    checks.database = !error;
  } catch {
    checks.database = false;
  }

  // Edge Functions reachability
  try {
    const res = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/generate-embedding`, {
      method: "OPTIONS",
    });
    checks.edge_functions = res.ok;
  } catch {
    checks.edge_functions = false;
  }

  const allHealthy = Object.values(checks).every(Boolean);

  return NextResponse.json(
    { status: allHealthy ? "healthy" : "degraded", checks, timestamp: new Date().toISOString() },
    { status: allHealthy ? 200 : 503 }
  );
}
```

Endpoint: `GET /api/health` — used by uptime monitors.

---

## 23.15 Operational Runbooks 

### Runbook: Deploy a Database Migration 

```
1. Ensure migration is committed and tested locally (supabase db reset)
2. Link to staging: supabase link --project-ref [staging-ref]
3. Apply to staging: supabase db push
4. Verify staging (manual QA or smoke test)
5. Link to production: supabase link --project-ref [prod-ref]
6. Apply to production: supabase db push
7. Verify production (check logs, test affected feature)
8. If failure: evaluate PITR restore or forward-fix
```

### Runbook: Add a New Edge Function 

```
1. Create directory: supabase/functions/[function-name]/index.ts
2. Import shared modules from _shared/
3. Implement function logic
4. Test locally: supabase functions serve [function-name]
5. Deploy to staging: supabase functions deploy [function-name]
6. Test on staging
7. Deploy to production: supabase functions deploy [function-name]
8. Set any new secrets: supabase secrets set [KEY]=[value]
9. Update this PRD if the function has user-facing behavior
```

### Runbook: Rotate OpenRouter API Key 

```
1. Generate new key at https://openrouter.ai/keys
2. Set in staging: supabase secrets set OPENROUTER_API_KEY=[new-key]
3. Verify staging AI calls work
4. Set in production: supabase secrets set OPENROUTER_API_KEY=[new-key]
5. Verify production AI calls work
6. Revoke old key in OpenRouter dashboard
```

### Runbook: Respond to Stripe Webhook Failure 

```
1. Check Stripe Dashboard → Developers → Webhooks → Event Deliveries
2. Identify failed events (red indicators)
3. Check Edge Function logs: supabase functions logs stripe-webhook --tail
4. If endpoint is down: fix and redeploy stripe-webhook function
5. In Stripe Dashboard: retry failed events (Stripe retries automatically for 72 hours)
6. Verify subscriptions table is consistent with Stripe state
```

### Runbook: Emergency Vercel Rollback 

```
1. Go to Vercel Dashboard → [project] → Deployments
2. Find the last known-good deployment
3. Click "..." → "Promote to Production"
4. Verify rollback is live (check https://aiconnected.app)
5. Investigate the broken deployment on the rolled-back commit
6. Fix → PR → merge → auto-deploys to production
```

---

## 23.16 Cross-Reference 

| Infrastructure Concern | Source Part(s) |
| :---- | :---- |
| Supabase project setup, Auth config | Part 2 |
| Database schema, tables, indexes, RLS | Part 18 |
| Edge Function implementations | Part 19 |
| Environment variables (Edge Functions) | Part 19 (Section 19.19) |
| Environment variables (Vercel) | Part 2 (Section 2.5), Part 20 (Section 20.13) |
| Storage buckets, policies | Part 18 (Section 18.15) |
| pg\_cron jobs | Part 18 (Section 18.14) |
| Migration order | Part 18 (Section 18.18) |
| Realtime channels | Part 17 |
| CI/CD: GitHub Actions, test pipeline | Part 22 (Section 22.9) |
| Performance benchmarks | Part 22 (Section 22.8) |
| Stripe products, webhook events | Part 2 (Section 2.3) |
| Browser proxy security | Part 11 (Section 11.8) |
| CORS headers | Part 19 (Section 19.2) |
`````
