# Omniloq — Architecture (v3) > SaaS d'agents IA conversationnels multi-canal & multi-tenant. > **Le cœur = le runtime d'agent. Les canaux = des adaptateurs branchables.** > v3 — intègre : ChannelAccount (1ère classe), secrets MCP par références, transactional outbox (Job), advisory lock par conversation, confirmation persistante, Response Guard/LeakDetector. --- ## 1. Vision & Principes 1. **Canal-agnostique** : le runtime ne sait pas s'il parle sur WhatsApp, Telegram ou le Web. 2. **Multi-tenant dès le jour 1** : chaque client = un `Tenant`. **Invariant : aucune opération sans `tenantId`** (repositories tenant-scoped). 3. **Multi-provider LLM** : DeepSeek, OpenAI, Gemini, Claude — sans changer le moteur. 4. **MCP au centre, jamais branché directement** : `AgentTool` (policy) fait le pont. Jamais de `if (tenant === "nchargi")`. 5. **Un cerveau, plusieurs canaux** : ajouter un canal = implémenter `Channel`. 6. **International** dès la conception. 7. **Reasoning ≠ outbound, par construction** + filet indépendant (Response Guard). --- ## 2. Stack technique - **Langage** : TypeScript strict, Node 22, ESM - **Monorepo** : pnpm workspaces + Turborepo - **Runtime agent** : Vercel AI SDK 7 (`ai`, `@ai-sdk/mcp`) - **HTTP** : Fastify · **ORM/DB** : Prisma + PostgreSQL - **Queue** : **PostgreSQL job queue** (transactional outbox) au début → BullMQ/Redis seulement si le volume le justifie - **Validation** : Zod · **Logging** : Pino · **Tests** : Vitest - **Secrets** : enveloppe (Master Key → DEK → secret), rotation, ownership. API keys hashées. - **LLM providers** : `@ai-sdk/deepseek`, `@ai-sdk/openai`, `@ai-sdk/google`, `@ai-sdk/anthropic`. --- ## 3. Structure monorepo ``` omniloq/ ├── apps/ │ ├── api/ # Fastify : ingestion canaux + API admin + routing │ └── web/ # Dashboard (Next.js + Tailwind + shadcn/ui) — Phase 4 └── packages/ ├── core/ # ⭐ Agent Runtime décomposé (voir §6) ├── channels/ # Interface Channel + implémentations │ ├── whatsapp/ # meta (d'abord), bird/gupshup (plus tard) │ ├── telegram/ │ └── web/ ├── db/ # Schéma Prisma + repositories tenant-scoped + job queue ├── config/ # Types config + Zod + Pino └── shared/ # Types, erreurs, normalisation, sanitization ``` **Règles** : `apps/api` → `core` + `channels` + `db`. `core` ne dépend **jamais** de `channels`. `channels` → `shared` seulement. --- ## 4. Architecture logique : Control Plane / Data Plane Séparation conceptuelle (même process Node au début) : ``` OMNILOQ ┌──────────────────────────┐ │ CONTROL PLANE │ │ Dashboard / Tenants │ │ Agents / Prompts / MCP │ │ Users / RBAC / Audit │ │ Billing │ └────────────┬─────────────┘ │ configuration ▼ ┌──────────────────────────┐ │ DATA PLANE │ │ Channel Adapter │ │ Event Ingestion │ │ Postgres (Message + Job) │ │ Conversation Dispatcher │ │ Conversation Lock │ │ Context Builder │ │ Agent Runner (AI SDK) │ │ Tool Policy │ │ Confirmation State ← DB │ │ MCP Executor │ │ Response Builder │ │ Response Guard │ │ Channel Sanitizer │ │ Outbound Dispatcher │ └──────────────────────────┘ ``` --- ## 5. Le contrat `InboundMessage` (riche, multimodal) ```typescript export type ChannelType = 'whatsapp' | 'telegram' | 'web'; export interface InboundMessage { id: string; channel: ChannelType; channelAccountId: string; // id de l'entité ChannelAccount externalMessageId: string; // id fournisseur (dédup) senderRef: string; // identité émetteur sur le canal conversationRef: string; // thread fournisseur si dispo timestamp: Date; content: ContentPart[]; replyTo?: string; metadata?: Record; } export type ContentPart = | { type: 'text'; text: string } | { type: 'image'; url: string; mimeType?: string } | { type: 'audio'; url: string; mimeType?: string } | { type: 'video'; url: string } | { type: 'document'; url: string; filename?: string } | { type: 'location'; lat: number; lng: number } | { type: 'interactive'; payload: unknown }; export interface Channel { readonly type: ChannelType; handleInbound(raw: unknown): Promise; sendMessage(identity: ChannelIdentity, content: OutboundMessage): Promise; verifyWebhook?(req: unknown): boolean; getStatus?(): { status: 'CONNECTED' | 'DISCONNECTED' | 'ERROR' }; } ``` --- ## 6. Agent Runtime décomposé ``` Channel Adapter (Channel) ↓ Event Ingestion → verify signature · dédupliquer ↓ Postgres transaction → insert Message + insert Job (transactional outbox) → COMMIT → ACK provider ↓ Queue Worker → SELECT ... FOR UPDATE SKIP LOCKED sur Job ↓ Conversation Dispatcher → résoudre tenant · agent · contact ↓ Conversation Lock → pg_advisory_xact_lock(conversationId) ← 1 seul run actif ↓ Context Builder → system prompt + vars + contact profile + summary + N derniers messages (budget maxContextTokens) ↓ Agent Runner → AI SDK : boucle tool-call, maxSteps. Final output UNIQUEMENT. ↓ Tool Policy → autorisation · confirmation · idempotence ↓ Confirmation State → persistant en DB (pas de coroutine suspendue) ↓ MCP Executor → exécution réelle du tool ↓ Response Builder → outbound (jamais le reasoning interne) ↓ Response Guard → LeakDetector (filet anti-fuite CoT, niveau runtime) ↓ Channel Sanitizer → format propre au canal (markdown WA, etc.) ↓ Outbound Dispatcher → Channel.sendMessage() ``` --- ## 7. Schéma Prisma (v3) ```prisma model Tenant { id String @id @default(cuid()) name String slug String @unique members TenantMember[] contacts Contact[] channelAccounts ChannelAccount[] agents Agent[] mcpServers McpServer[] secrets Secret[] auditLogs AuditLog[] conversations Conversation[] } model User { id String @id @default(cuid()) email String @unique passwordHash String? memberships TenantMember[] } model TenantMember { id String @id @default(cuid()) tenantId String userId String role String @default("OPERATOR") // OWNER | ADMIN | DEVELOPER | OPERATOR | VIEWER tenant Tenant @relation(fields: [tenantId], references: [id]) user User @relation(fields: [userId], references: [id]) @@unique([tenantId, userId]) } // ── Personne physique (résolution cross-canal différée) ── model Contact { id String @id @default(cuid()) tenantId String identities ChannelIdentity[] conversations Conversation[] tenant Tenant @relation(fields: [tenantId], references: [id]) } // ── Compte de canal (1ère classe) : un numéro WA / bot TG / widget ── model ChannelAccount { id String @id @default(cuid()) tenantId String channelType String // whatsapp | telegram | web provider String? // meta | bird | gupshup externalAccountId String? // phone number id / bot username / widget id label String? status String @default("DISCONNECTED") config Json? // non-secret (numéro, callback, ...) credentialsRef String? // pointe vers Secret (nom), jamais en clair tenant Tenant @relation(fields: [tenantId], references: [id]) identities ChannelIdentity[] conversations Conversation[] @@index([tenantId]) } // ── Identité d'un contact sur un canal précis ── model ChannelIdentity { id String @id @default(cuid()) tenantId String contactId String? channelType String channelAccountId String externalUserId String contact Contact? @relation(fields: [contactId], references: [id]) channelAccount ChannelAccount @relation(fields: [channelAccountId], references: [id]) conversations Conversation[] @@unique([channelType, channelAccountId, externalUserId]) } // ── Agent + révisions ── model Agent { id String @id @default(cuid()) tenantId String name String activeRevisionId String? tenant Tenant @relation(fields: [tenantId], references: [id]) revisions AgentRevision[] tools AgentTool[] conversations Conversation[] } model AgentRevision { id String @id @default(cuid()) agentId String systemPrompt String provider String model String providerOptions Json? maxSteps Int @default(8) temperature Float @default(0.3) toolsetVersion String createdAt DateTime @default(now()) createdBy String? agent Agent @relation(fields: [agentId], references: [id]) runs AgentRun[] } // ── Tool Policy (autorisation explicite) ── model AgentTool { id String @id @default(cuid()) agentId String mcpServerId String toolName String enabled Boolean @default(true) requiresConfirmation Boolean @default(false) riskLevel String @default("low") timeoutMs Int @default(15000) maxCallsPerRun Int @default(3) idempotencyRequired Boolean @default(false) allowedRoles Json? agent Agent @relation(fields: [agentId], references: [id]) mcpServer McpServer @relation(fields: [mcpServerId], references: [id]) @@unique([agentId, toolName]) } model McpServer { id String @id @default(cuid()) tenantId String name String transport String @default("http") // http | sse | stdio url String? headersTemplate Json? // {"Authorization": "Bearer {{secret:MCP_TOKEN}}"} — JAMAIS de secret en clair enabled Boolean @default(true) tenant Tenant @relation(fields: [tenantId], references: [id]) agentTools AgentTool[] } // ── Conversation (indépendante de l'identité ; machine d'état handoff) ── model Conversation { id String @id @default(cuid()) tenantId String agentId String channelIdentityId String externalThreadId String? mode String @default("AI") // AI | HUMAN | HYBRID status String @default("open") assignedToUserId String? handoffReason String? handoffAt DateTime? resumePolicy String? startedAt DateTime @default(now()) closedAt DateTime? // sérialisation : advisory lock PostgreSQL (pg_advisory_xact_lock) en plus lockVersion Int @default(0) tenant Tenant @relation(fields: [tenantId], references: [id]) agent Agent @relation(fields: [agentId], references: [id]) channelIdentity ChannelIdentity @relation(fields: [channelIdentityId], references: [id]) messages Message[] runs AgentRun[] @@index([tenantId, agentId, channelIdentityId]) } // ── Message riche ── model Message { id String @id @default(cuid()) conversationId String externalMessageId String? direction String // inbound | outbound senderType String // contact | agent | human | system content Json // ContentPart[] replyToMessageId String? runId String? status String @default("received") providerMetadata Json? createdAt DateTime @default(now()) deliveredAt DateTime? readAt DateTime? conversation Conversation @relation(fields: [conversationId], references: [id]) @@index([conversationId]) } // ── Exécution ── model AgentRun { id String @id @default(cuid()) conversationId String agentRevisionId String triggerMessageId String? provider String model String startedAt DateTime @default(now()) finishedAt DateTime? status String // pending | running | completed | failed | cancelled inputTokens Int @default(0) outputTokens Int @default(0) cost Float @default(0) latencyMs Int @default(0) steps Int @default(0) conversation Conversation @relation(fields: [conversationId], references: [id]) revision AgentRevision @relation(fields: [agentRevisionId], references: [id]) toolCalls ToolCall[] } model ToolCall { id String @id @default(cuid()) runId String toolName String mcpServerId String? arguments Json idempotencyKey String // généré par Omniloq, PAS par le LLM status String // pending | executing | success | error | awaiting_confirmation result Json? error String? startedAt DateTime @default(now()) finishedAt DateTime? run AgentRun @relation(fields: [runId], references: [id]) @@index([runId]) } // ── Job queue (transactional outbox) ── model Job { id String @id @default(cuid()) tenantId String conversationId String type String payload Json status String @default("pending") // pending | locked | done | failed availableAt DateTime @default(now()) lockedAt DateTime? lockedBy String? attempts Int @default(0) maxAttempts Int @default(5) dedupKey String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([status, availableAt]) @@index([conversationId]) } // ── Secrets ── model Secret { id String @id @default(cuid()) tenantId String name String encryptedValue String // DEK-chiffré (AES-256-GCM), DEK enveloppé par Master Key keyVersion Int @default(1) rotationPolicy String? tenant Tenant @relation(fields: [tenantId], references: [id]) @@unique([tenantId, name]) } // ── Audit ── model AuditLog { id String @id @default(cuid()) tenantId String userId String? action String entity String entityId String? before Json? after Json? createdAt DateTime @default(now()) tenant Tenant @relation(fields: [tenantId], references: [id]) @@index([tenantId, createdAt]) } // ── Variables ── model Variable { id String @id @default(cuid()) agentId String key String value String @@unique([agentId, key]) } ``` --- ## 8. Tool Policy & Confirmation (persistante, reprenable) - **La confirmation n'est PAS une instruction de prompt.** Politique déclarée par tool (`requiresConfirmation`, `riskLevel`, `timeoutMs`, `maxCallsPerRun`). - **Confirmation persistante** : un "Run suspendu" est un **état en DB**, jamais une coroutine/process en attente. ``` Run 100 → ToolCall 200 → status = awaiting_confirmation (persisté) Utilisateur : "oui" → ConfirmationEvent → valider ToolCall 200 → exécuter le tool → nouveau AgentRun 101 (ou continuation logique liée au run précédent) ``` Une confirmation peut arriver 30 s, 5 min ou 2 h plus tard — on ne garde **rien** suspendu en mémoire. - Un MCP qui expose un nouvel outil n'ouvre pas l'accès automatiquement (il faut un `AgentTool`). ## 9. Idempotence (générée par Omniloq) - Le LLM n'invente jamais `idempotency_key`. Omniloq génère `tenant + conversation + run + toolCall`. - **At-least-once delivery + side effects idempotents** (pas de rêve d'exactly-once avec webhooks + retries). ## 10. Reasoning ≠ outbound + Response Guard - Le canal n'a jamais accès au raisonnement interne. `Response Builder` ne prend que le final output. - **Response Guard (LeakDetector)** : un détecteur au niveau runtime, **pas un regex destructif dans chaque canal**. Fuite de reasoning = problème LLM/runtime, pas WhatsApp. ``` final output → LeakDetector → OK → envoyer → suspect → bloquer / nettoyer contrôlé / régénérer le final ``` - Le **Channel Sanitizer** (format : markdown WA, etc.) reste séparé, appliqué après le guard. ## 11. Fiabilité : transactional outbox + queue + sérialisation - **Transactional outbox** : le message entrant et le job sont créés dans la **même transaction PostgreSQL** : ``` Webhook → BEGIN → insert Message → insert Job → COMMIT → ACK provider ``` Pas de "message sauvé puis crash → job jamais créé". - **Queue PostgreSQL** : table `Job`, workers `SELECT ... FOR UPDATE SKIP LOCKED`. Node + PostgreSQL seulement au début (BullMQ/Redis plus tard si besoin). - **Sérialisation par conversation** : `pg_advisory_xact_lock(conversationId)` pris au début du traitement → **maximum un AgentRun actif par conversation**. `lockVersion` reste pour l'optimistic locking des updates de config. ## 12. RBAC, Audit, Multi-tenant - **RBAC** : `TenantMember.role` ∈ { OWNER, ADMIN, DEVELOPER, OPERATOR, VIEWER }. - **AuditLog** : chaque action sensible journalisée. - **Isolation tenant** : repositories imposent `tenantId`. PostgreSQL RLS possible plus tard. ## 13. Widget Web (sécurité) - `localStorage session id` = identité anonyme légère, jamais fiable. - **public widget key** + **domain allowlist** + **signed anonymous session token** + rate limiting + CORS/origin validation. ## 14. Canaux - **WhatsApp** : Meta d'abord. Bird/Gupshup seulement si besoin réel. - **Onboarding abstrait** : `Connect Channel` → provider-specific (Meta = Embedded Signup, Bird/Gupshup = credentials). Pas de "QR universel". - **Telegram** : Bot Token → webhook. - **Web** : bundle `omniloq.js` + WebSocket. ## 15. Interface Utilisateur (Dashboard) **Niveau Utilisateur** : onboarding · agents · canaux · conversations (handoff, passthrough) · usage/facturation · paramètres. **Niveau Expert/Admin** : MCP servers (test connexion + tools découverts) · variables · prompt versioning (diff/rollback, soutenu par `AgentRevision`) · tracing/logs (runs, tool calls, erreurs) · modèles LLM + fallback · API/webhooks · playground. **Handoff = machine d'état** (`Conversation.mode` AI/HUMAN/HYBRID, `assignedToUserId`, `resumePolicy`). ## 16. Métriques billing dès le jour 1 Chaque `AgentRun` enregistre : tenant, agent, model, input/output tokens, cost, tool calls, duration. --- ## 17. Roadmap (révisée) - **Phase 0 — Spike** : AI SDK + mock MCP + boucle tool-call (indépendant de Nchargi). - **Phase 0.5 — Modèle exécution** : `AgentRevision`, `AgentRun`, `ToolCall`, vraie structure `Message`. - **Phase 1 — Socle données** : PostgreSQL + `Tenant` + `ChannelAccount` + `Contact` + `ChannelIdentity` + `Conversation` + `Job` (outbox) + repositories tenant-scoped. - **Phase 1.5 — Cerveau** : `ContextBuilder` + `ToolPolicy` + confirmation persistante + Response Guard. - **Phase 2 — WhatsApp Meta** : ingestion async + déduplication + advisory lock par conversation. - **Phase 2.5 — Observabilité** : tracing + token/cost tracking. - **Phase 3 — Telegram** : valide l'abstraction `Channel`. - **Phase 4 — Dashboard minimal**. - **Phase 5 — Web widget** (token signé). - **Phase 6 — Bird/Gupshup** : si besoin réel. - **Phase 7 — Billing, API publique, avancé.** --- ## 18. Déploiement (esquisse) - Sous-domaines : `api.omniloq.com`, `app.omniloq.com`, `widget.omniloq.com` (+ `files.omniloq.com` déjà en place). - Docker Compose : api + postgres (+ redis si BullMQ). nginx + certbot. - Serveur : VPS dédié recommandé (le serveur actuel héberge nchargi/chancha/keepaneye/startdpp).