Skip to main content

AI Architecture

The AI framework is a typed, module-owned runtime built on the Vercel AI SDK. It deliberately runs alongside the older OpenCode-backed Cmd+K Code Mode chat — the two stacks never share state and never share endpoints.

Use this page when you want a single map of every piece: packages, request flow, persistence, events, and where the wires cross.

Package map

PackageWhat lives here
@saasframe/ai-assistantRuntime: runAiAgentText, runAiAgentObject, prepareMutation, model factory, agent + tool registries, mutation-approval gate, pending-action repo + worker, dispatcher routes, health/agents APIs, agent settings UI
@saasframe/ui (/ai)Client surface: <AiChat>, <AiAssistantLauncher>, useAiChat, useAiChatUpload, the UI-part registry, the four mutation approval cards, the typed record-card components
@saasframe/coreDomain modules contributing agents and tool packs (customers, catalog, inbox_ops, …). Each module ships ai-agents.ts + ai-tools.ts at its root
@saasframe/cliThe generator that aggregates per-module ai-agents.ts / ai-tools.ts into apps/<app>/.saasframe/generated/ai-agents.generated.ts + ai-tools.generated.ts
@saasframe/sharedAuth, RBAC, encryption, i18n helpers used by every dispatcher route. The agent runtime never imports from here directly except through DI

Request flow — chat agent

<AiChat agent="<module>.<agent>" pageContext={{...}} />
│ (fetch, SSE)

POST /api/ai_assistant/ai/chat?agent=<module>.<agent>

├── getAuthFromRequest() — shared cookie/JWT/API-key resolver
├── checkAgentPolicy() — requiredFeatures, allowedTools whitelist,
│ readOnly + mutationPolicy, acceptedMediaTypes
├── createModelFactory() — caller override → SF_AI_<MODULE>_MODEL → agent default
├── resolveAiAgentTools() — typed tool-pack filtering, system-prompt compose,
│ resolvePageContext hydration
├── resolveAttachmentPartsForAgent() — base64 inline / signed URL / extracted text


runAiAgentText({ model, tools, system, messages, attachments })
│ (streamText)
├── tool call (read) → handler(args, McpToolContext) → JSON
├── tool call (mutation) → adaptToolToAiSdk wraps with prepareMutation():
│ insert ai_pending_actions row,
│ enqueue mutation-preview-card AiUiPart


SSE stream text-delta / reasoning-delta / tool-* / data-aiui (UI parts)


useAiChat parser → assistant message + AiChatMessageUiPart entries


<AiChat> renders text, reasoning panel, tool calls, record cards, approval cards

Request flow — confirmation

mutation-preview-card → user clicks Confirm


POST /api/ai/actions/:id/confirm

├── pending-action-recheck — re-read each target row, compare record_version
├── tx { executePendingActionConfirm() }
│ ├── tool.handler(action.normalizedInput, ctx { tool, container, ... })
│ ├── update ai_pending_actions row → status=confirmed (or failed)
│ └── emit ai.action.confirmed (or ai.action.failed) on the system bus


ai.action.confirmed → DOM event bridge (clientBroadcast on consumer module's
events.ts) → useAppEvent in <DataTable> → refresh

POST /api/ai/actions/:id/cancel is the symmetric cancel path. The TTL cleanup worker (ai_assistant:pending-action-cleanup, 5-min interval) flips stale pending rows to expired and emits ai.action.expired.

Persistence

TableOwnerPurpose
ai_pending_actions@saasframe/ai-assistantOne row per staged mutation. Drives the approval card and the confirm/cancel routes
ai_agent_prompt_overrides@saasframe/ai-assistantPer-tenant additive prompt-section overrides authored at /backend/config/ai-assistant/agents
ai_agent_mutation_policy_overrides@saasframe/ai-assistantPer-tenant downgrade-only mutation policy. Cannot escalate above the code-declared policy
attachments@saasframe/coreEvery chat upload lands here. The agent runtime resolves bytes / signed URL / extracted text from this table

All three new tables ship via Migration20260419134235_ai_assistant. After yarn db:migrate, run:

yarn saasframe configs cache structural --all-tenants

…so existing tenants register the cleanup worker.

Events (FROZEN ids)

EventEmitted byUse it to…
ai.action.confirmedexecutePendingActionConfirmRefresh DataTables, fan out per-record domain events, audit the approval
ai.action.cancelledexecutePendingActionCancelAudit the rejection, clean up any optimistic UI
ai.action.expiredTTL worker + cancel-race short-circuitNotify the operator their proposal lapsed

All three use category: 'system', entity: 'ai_pending_action'. Set clientBroadcast: true in the consumer module's events.ts if a browser surface needs them; see Mutation Approvals.

HTTP surface

RouteUsed byPurpose
POST /api/ai_assistant/ai/chat?agent=...<AiChat>Stream chat tokens + AiUiParts
GET /api/ai_assistant/ai/agents<AiAssistantLauncher>, agent settingsList agents the caller can invoke (filtered by ACL features)
GET /api/ai_assistant/health<AiAssistantLauncher>Render-or-hide gate — non-2xx hides every AI launcher in the chrome
GET /api/ai/actions/:id<AiChat> rehydrationReconnect / poll the current pending-action state
POST /api/ai/actions/:id/confirmApproval cardRun the confirm handler
POST /api/ai/actions/:id/cancelApproval cardAtomic cancel
POST /api/attachmentsuseAiChatUploadMultipart upload from the chat composer (per-file 60s timeout)

Generators

yarn generate drives two file aggregations the runtime depends on:

Generated fileSourceConsumed by
apps/<app>/.saasframe/generated/ai-agents.generated.tsevery <module>/ai-agents.ts (root file only)loadAgentRegistry() at first agent invocation
apps/<app>/.saasframe/generated/ai-tools.generated.tsevery <module>/ai-tools.ts (root file only)registerGeneratedAiToolEntries() at boot

Agents and tools must live at the module root — sub-files (ai-tools/<surface>-pack.ts) are imported from the root file but never auto-discovered.

Where each piece lives in @saasframe/ai-assistant

src/modules/ai_assistant/
├── api/ # Dispatcher + agents + actions + health routes
│ ├── ai/
│ │ ├── chat/route.ts # POST chat — runAiAgentText
│ │ ├── chat-object/route.ts # POST object — runAiAgentObject
│ │ └── agents/route.ts # GET agents
│ ├── ai/actions/[id]/
│ │ ├── confirm/route.ts # POST confirm
│ │ ├── cancel/route.ts # POST cancel
│ │ └── route.ts # GET (rehydrate)
│ └── health/route.ts # GET health (used by the launcher gate)

├── lib/
│ ├── agent-runtime.ts # runAiAgentText / runAiAgentObject
│ ├── agent-policy.ts # checkAgentPolicy + resolveEffectiveMutationPolicy
│ ├── agent-tools.ts # resolveAiAgentTools, mutation interceptor
│ ├── agent-registry.ts # loadAgentRegistry, listAgents
│ ├── tool-registry.ts # toolRegistry
│ ├── tool-loader.ts # registerGeneratedAiToolEntries
│ ├── tool-executor.ts # executeTool (read tools)
│ ├── prepare-mutation.ts # writes ai_pending_actions, returns AiUiPart
│ ├── pending-action-executor.ts # executePendingActionConfirm
│ ├── pending-action-cancel.ts # executePendingActionCancel
│ ├── pending-action-recheck.ts # stale-version guard
│ ├── attachment-parts.ts # bytes / signed URL / extracted text
│ ├── model-factory.ts # createModelFactory + resolution order
│ └── llm-adapters/ # provider registry

├── data/
│ ├── entities/ # ai_pending_actions, prompt overrides, policy overrides
│ └── repositories/AiPendingActionRepository.ts

├── workers/
│ └── pending-action-cleanup/ # TTL worker

├── backend/config/ai-assistant/ # Agent settings UI (prompt + policy overrides)
└── frontend/ # Legacy OpenCode command palette (unchanged)

Where each piece lives in @saasframe/ui/ai

packages/ui/src/ai/
├── AiChat.tsx # Chat sheet — composer, transcript, attachments, UI parts
├── AiAssistantLauncher.tsx # Topbar launcher + Cmd/Ctrl+L global shortcut
├── AiDock.tsx # Right-side docked chat surface
├── AiChatSessions.tsx # Multi-session tabs backed by server conversations
├── ChatPaneTabs.tsx # Tab strip for the dock
├── useAiChat.ts # Server transcript hydrate/import, SSE parser, request builder
├── conversation-store.ts # Browser adapter for /api/ai_assistant/ai/conversations
├── useAiChatUpload.ts # Upload state + per-file progress
├── upload-adapter.ts # multipart POST /api/attachments + per-file timeout
├── useAiShortcuts.ts # Cmd+Enter / Esc shortcuts shared by every dialog
├── ui-part-registry.ts # AiUiPartRegistry — register/resolve componentIds
├── ui-part-slots.ts # Reserved part ids
├── AiMessageContent.tsx # Markdown + record-card fence parser
├── records/ # Typed record cards (deal, person, company, product, activity)
└── parts/ # Mutation approval cards (preview, field-diff, confirm, result)

Coexistence with OpenCode Code Mode

OpenCode is the older AI surface that powers the Cmd+K command palette. It shares nothing with the new framework at runtime:

SurfaceOpenCode (Cmd+K)New framework
Chat entrypointPOST /api/chat (Code Mode SSE)POST /api/ai_assistant/ai/chat?agent=...
Tool transportMCP HTTP server :3001 (legacy registerMcpTool)Typed tool packs registered via defineAiTool
Mutation gateNone (Code Mode is read-only by design)ai_pending_actions + approval cards
UIRaycast-style command palette<AiChat>, <AiAssistantLauncher>, AI Agent settings
KeyboardCmd+K (palette), Cmd+J (chat)Cmd/Ctrl+L (global launcher)

Both can be used in the same tenant — one for ad-hoc Code Mode exploration, the other for focused, mutation-capable agents with the approval contract. Disable either by removing the corresponding ACL feature (ai_assistant.view / search.global / etc.) from role grants.

Where to go next