Documentation source
Chat System
Multi-agent AI chat with conversation types, participant models, message persistence via AI SDK v6 parts, agent selection, entity scoping, and tool result rendering.
## Overview
The chat system is the primary interface for human-agent collaboration in the Sprinter Platform. Users converse with AI agents who have access to the full entity graph, documents, web search, and workflow tools. Every conversation is persisted, scoped to a tenant, and optionally linked to a specific entity for contextual assistance.
The system supports three conversation types -- AI chat (user talks to an agent), direct messaging (user to user), and group chat (multiple participants with optional auto-responding agents). In practice, the AI chat type dominates usage today.
Chat is built on AI SDK v6, using `streamText` for streaming responses and `UIMessage` parts for rich content persistence. Messages are stored in the `messages` table (not `chat_messages` -- this is a common gotcha).
## Key Concepts
### Chat Types
```typescript
type ChatType = "ai" | "direct" | "group";
```
- **ai** -- A user converses with a single AI agent. The agent is selected at chat creation and can be switched mid-conversation.
- **direct** -- One-to-one messaging between two users.
- **group** -- Multi-participant conversations mixing users and agents. Agents with `auto_respond: true` reply automatically when mentioned or when a message is directed at them.
### Participant Model
```typescript
type ParticipantType = "user" | "agent";
type ParticipantRole = "owner" | "member";
```
Each chat has participants tracked via the `chat_participants` table. Participants have a type (user or agent), a role (owner or member), and an `auto_respond` flag that controls whether agent participants reply without being explicitly invoked.
### Message Parts (AI SDK v6)
Messages use the AI SDK v6 `UIMessage` format with typed `parts` rather than plain text. The `parts` column (jsonb) on the `messages` table stores the full rich content:
- **text** -- Plain text content from the user or agent.
- **reasoning** -- Model reasoning/thinking tokens.
- **tool-\{toolName\}** -- Tool invocation with input, output, and state tracking.
- **dynamic-tool** -- Dynamically resolved tool calls.
- **step-start** -- Multi-step boundaries.
- **file** -- Attached files with media type and URL.
- **source-url** / **source-document** -- Citation references.
The `SerializedPart` type is the canonical union used for persistence:
```typescript
type SerializedPart = UIMessagePart<UIDataTypes, UITools>;
```
### Chat Record
```typescript
interface ChatRecord {
id: string;
tenant_id: string;
workspace_id: string | null; // stamped at creation from the active workspace URL
user_id: string | null;
title: string | null;
entity_id: string | null;
entity_type_slug: string | null;
agent_id: string;
created_at: string;
updated_at: string;
}
```
Chats are tenant-scoped and user-owned. The `entity_id` and `entity_type_slug` fields scope a chat to a specific entity, injecting that entity's context into the agent's system prompt. The `agent_id` determines which agent handles the conversation.
`workspace_id` is stamped at creation time from the active workspace URL. This enables the chat history sidebar to filter conversations per workspace via a toggle. `workspace_id` uses `ON DELETE SET NULL` (not CASCADE) — deleting a workspace does not delete chat history, it only detaches the workspace association.
## How It Works
### Conversation Lifecycle
1. **Creation** -- `createChat()` server action creates a new chat record. If no `agentId` is provided, the system resolves a default agent by searching for the tenant's configured primary-agent slug from `product.config.ts`, then falling back to the matching system agent and finally any enabled agent. The action also stamps `chats.workspace_id` from the active workspace URL (via `getActiveWorkspace()`) so conversations are naturally scoped to the workspace where they started.
2. **Message Flow** -- The user sends a message via the chat UI. The client uses `DefaultChatTransport` (AI SDK v6) to stream messages to the `/api/chat` route. The route resolves the agent, builds its tool set (filtered by user permissions), loads conversation history, and calls `streamText`.
3. **Persistence** -- `saveChatMessage()` persists each message with both a plain `content` string (backward compatibility) and structured `parts`. If the message is the first user message and the chat has no title, an auto-title is generated from the message content (truncated to 60 characters). After saving the message, the function updates the chat's `updated_at` timestamp; the UPDATE now uses `.select("id")` and throws a descriptive error if Supabase returns a failure, rather than silently dropping the error.
4. **Agent Selection** -- Users switch agents mid-conversation via `updateChat()`, which updates the `agent_id`. The chat route resolves agents from the database, using tenant agents and system agents through the same execution pipeline. The agent's system prompt, model, and tool configuration are all respected.
5. **Ownership Enforcement** -- The AI chat REST endpoints (`/api/chats`, `/api/chats/[id]`, `/api/chats/[id]/messages`, `/api/chats/[id]/feedback`) now require authentication at the route boundary and reuse a shared owned-chat verification helper in server code. This keeps list/detail/message/feedback behavior aligned and avoids drift between route handlers and persistence actions.
### Client Data Fetching
The chat UI uses React Query for simple cached GETs such as model selection and persisted message history, but it intentionally keeps raw fetches in the parts of the flow that are coupled to AI SDK streaming and local optimistic state:
- `ModelSelector` now reads from the shared models hook instead of issuing its own mount-time request.
- Message history hydration still coordinates with `useChat()` and the internal chat creation guard, so it remains in the chat panel.
- Send, read-receipt, and dock polling paths remain direct fetches because they are tightly coupled to live transport, unread semantics, or serialized persistence order.
This split keeps the cached data paths consistent without forcing streaming behavior into a cache abstraction that does not fit it cleanly.
### Message Serialization and Deserialization
The `message-utils.ts` module provides pure, framework-agnostic utilities for converting between AI SDK message formats and database storage:
- **`serializePartsForDB(parts)`** -- Validates and normalizes message parts for persistence. Only retains valid AI SDK v6 part types; strips malformed entries.
- **`deserializePartsFromDB(metadata, content)`** -- Reconstructs `UIMessage` parts from stored data, falling back to a text-only part when no parts are found.
- **`toUIMessage(message, index)`** -- Converts a raw database row into a typed `ChatUIMessage` for the AI SDK.
- **`collectMessagePartsFromFullStream(stream)`** -- Collects streaming chunks into finalized `content` and `parts` for persistence. Tool parts are tracked via a `Map` keyed by `toolCallId`, with upsert semantics so progressive updates (input-streaming, input-available, output-available) coalesce into a single part.
### Conversation History Replay
When the chat route loads history for context, it must sanitize persisted parts before sending them to the model:
- **`getReplayableMessageParts(parts, tools)`** -- Filters out parts that cannot be replayed: `step-start` markers, `dynamic-tool` entries, tool parts without input, tool parts referencing tools not in the current tool set, and tool parts whose persisted input no longer validates against the current tool schema. This prevents model errors when the agent's tool configuration has changed since the conversation started or an interrupted tool stream left behind partial input.
- **`validateAndSanitizeMessagesForModel({ messages, tools })`** -- Full pipeline: normalizes each message to a `UIMessage`, strips non-replayable parts, falls back to text content when no parts survive, then runs AI SDK's `validateUIMessages` for final validation.
### Tool Result Display
Tool invocations are tracked as typed parts within messages. Each tool part progresses through states:
- `input-streaming` -- Tool input is being generated by the model.
- `input-available` -- Tool input is complete, execution begins.
- `output-available` -- Tool completed successfully with output data.
- `output-error` -- Tool execution failed.
- `output-denied` -- Tool execution was denied (permission or policy).
The chat UI auto-expands tool results. Custom tool output UIs (a `ViewSpec` registered via `registerViewSpec("tool:<slug>", spec)`) render rich output displays; tools without custom UI fall back to `GenericToolOutput`. (The legacy `registerToolUI()` API is fully retired — see [Tool System](/docs/features/tool-system).)
Helper functions for tool state inspection:
```typescript
function extractToolName(partType: string): string; // "tool-searchEntities" -> "searchEntities"
function isToolRunning(state?: string): boolean; // input-streaming or input-available
function isToolDone(state?: string): boolean; // output-available or output-error
```
### Markdown Link Handling
Assistant messages and reasoning blocks render via Streamdown (`components/ai-elements/message.tsx`, `reasoning.tsx`). Chat overrides Streamdown's default `<a>` renderer with `MarkdownLink` (`components/ai-elements/markdown-link.tsx`) so internal navigation can stay in-app without giving up guarded external links:
- Internal hrefs (app-relative paths, explicit relative URLs like `./foo` / `?tab=bar`, or absolute URLs whose origin matches the current window) render as `next/link`, so clicks trigger client-side navigation and preserve the chat sidebar's React state.
- Same-origin absolute URLs are normalized to app-relative hrefs before they hit `next/link`, avoiding hard navigations even when the model emits a full preview URL.
- External hrefs render as guarded buttons that open a confirmation dialog before calling `window.open(...)` in a new tab. The dialog keeps the same copy/open affordance as Streamdown's built-in safety flow while letting internal links bypass it.
- Streaming placeholder hrefs (`streamdown:incomplete-link`) render as inert spans.
Classification and normalization live in `lib/internal-href.ts` (`isInternalHref`, `getInternalNavigationHref`). Callers pass `components={{ a: MarkdownLink }}` to `<Streamdown>` in both chat messages and reasoning blocks.
### Entity-Scoped Chat
When a chat is created with `entityId` and `entityTypeSlug`, the agent receives entity context injection -- the entity's title, type, and current fields are included in the system prompt. The chat route now reloads this context server-side from the tenant-scoped database instead of trusting client-supplied title/content values. This keeps prompts aligned to the latest stored truth and avoids stale or user-shaped system context.
### Inbox Conversations
Inbox-style direct and group conversations are backed by the same `messages` table but use the `chat_participants` model instead of single-owner semantics. The high-churn list shaping logic for participant display names, last-message selection, and unread counts now lives in dedicated pure helpers so the inbox server-action module can stay focused on data access and agent execution rather than inline result-massaging.
### Workspace Business Context
Chat runs load the resolved `agent_context` profile via `getResolvedAgentContext(tenantId, workspaceId?)` and append it as a dedicated "Workspace Business Context" prompt section. The resolver applies the 4-tier merge (`user > workspace > tenant > platform`): workspace-scoped keys override tenant-level defaults on a per-key basis, while array values replace wholesale. This gives agents workspace-specific business background without requiring per-workspace prompt rewrites.
The chat history sidebar renders a **workspace filter toggle** when inside a workspace URL. Toggling it restricts the history list to chats where `workspace_id = activeWorkspaceId`, making it easy to find conversations started in the current workspace context.
### Agent Resolution Cascade
The `resolveDefaultChatAgentId` function follows a strict resolution order:
1. Tenant-specific agent with the configured primary-agent slug from `product.config.ts` and `enabled = true`.
2. System-level agent with that same slug and `is_system = true`.
3. Any enabled tenant or system agent returned by the fallback lookup.
4. Throws "No agents configured" if all fail.
## API Reference
### Server Actions (`features/chat/persistence.ts`)
| Function | Signature | Description |
| ----------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `getChats` | `(limit?: number) => Promise<ChatRecord[]>` | Lists the current user's AI chats, ordered by most recent. |
| `getChatById` | `(chatId: string) => Promise<ChatRecord \| null>` | Fetches a single chat, verifying user ownership and tenant scope. |
| `getChatMessages` | `(chatId: string) => Promise<ChatMessageRecord[]>` | Returns all messages for a chat, ordered chronologically. Verifies chat ownership before querying messages. |
| `createChat` | `(input: { title?, entityId?, entityTypeSlug?, agentId? }) => Promise<ChatRecord>` | Creates a new chat. Resolves default agent if none specified. |
| `saveChatMessage` | `(input: { chatId, role, content, parts?, metadata? }) => Promise<void>` | Persists a message. Auto-titles the chat on the first user message. Parts priority: explicit `parts` field, then `metadata.parts` fallback, then text-only. |
| `updateChat` | `(chatId: string, input: { agentId? }) => Promise<ChatRecord>` | Updates chat properties (currently just agent switching). |
| `deleteChat` | `(chatId: string) => Promise<void>` | Deletes a chat and its messages (cascade). |
| `getDefaultChatAgentId` | `() => Promise<string>` | Returns the default agent ID for the current tenant. |
### Message Utilities (`features/chat/message-utils.ts`)
| Function | Purpose |
| ------------------------------------- | ---------------------------------------------- |
| `serializePartsForDB` | Normalize parts array for database storage. |
| `deserializePartsFromDB` | Reconstruct typed parts from DB record. |
| `normalizeStoredParts` | Filter and validate raw parts array. |
| `getTextPartsText` | Extract concatenated text from all text parts. |
| `toUIMessage` | Convert raw DB row to AI SDK `UIMessage`. |
| `getReplayableMessageParts` | Filter parts safe for model replay. |
| `validateAndSanitizeMessagesForModel` | Full sanitization pipeline for model input. |
| `collectMessagePartsFromFullStream` | Collect streaming chunks into finalized parts. |
| `extractToolName` | Parse tool name from part type string. |
| `isToolRunning` / `isToolDone` | Check tool execution state. |
## For Agents
Agents interact with the chat system in two directions:
**Receiving messages** -- The chat route invokes the agent with the user's message, conversation history, and available tools. The agent's response is streamed back and persisted.
**Using tools** -- Agents have access to entity tools (`searchEntities`, `getEntity`, `createEntity`, etc.), web search, workflow tools, context tools, admin tools, user-facing tools, and the delegation tool. Tool availability is filtered by the user's permissions -- agents in supervised mode (chat) inherit the user's permission set.
**Delegation** -- Agents can delegate tasks to other agents (internal or external) via `delegateToAgent(agentSlug, task)`. The delegation tool is automatically included in every agent's tool set.
**Entity context** -- When a chat is scoped to an entity, the agent's system prompt includes entity details, enabling natural queries about that specific record.
## Design Decisions
**Messages table, not chat_messages.** The platform uses a single `messages` table with AI SDK v6 `parts` support. An earlier schema used `chat_messages` -- this was consolidated. All code must reference `messages`.
**Parts-first persistence.** The `content` column is a plain-text fallback for backward compatibility and search. The `parts` column is the source of truth for rich content including tool invocations, reasoning tokens, and file attachments.
**Lazy auto-titling.** Chat titles are generated automatically from the first user message rather than requiring an explicit title. This reduces friction while keeping the history sidebar useful.
**Permission-filtered tools.** The chat route passes the user's permissions to `resolveAgentTools()`, which excludes tools the user cannot access. This means the agent never sees tools it cannot use -- cleaner than post-hoc permission checks.
**Compact prompt context beats raw dumps.** The prompt includes a bounded summary of available data types and tells the agent to call `listEntityTypes({ detailed: true })` when it actually needs the full schema. This keeps context rich enough for orientation without paying to inject every field config on every turn.
**Provider-aware long-chat controls.** Anthropic-backed chat runs use prompt caching for stable system/tool definitions and provider-side context management to clear old tool-use payloads from long histories. This reduces cost while preserving the user's recent working context. The runtime records cache-read, cache-write, reasoning, and applied context-edit telemetry after each completed run so admin cost views can measure whether those controls are paying off.
**Agent resolution cascade.** The multi-step fallback ensures that chats always have a valid agent, even in upgraded installs where the `agents` table has not been seeded yet. Tenant agents take priority over system agents to support per-tenant customization.
## Related Modules
- **Agent System** (`features/agents/`) -- Agent definitions, tool resolution, heartbeat scheduling.
- **Tool System** (`features/tools/`) -- Tool registry, execution engine, AI bridge for user-facing tools.
- **Entity System** (`features/entities/`) -- Entity CRUD, the data that agents search and modify.
- **Block System** (`features/blocks/`) -- `chatOutputToBlocks()` bridge converts tool output to blocks for rendering.
- **Actions and Sessions** (`features/actions/`, `features/sessions/`) -- Agents can trigger work and inspect execution transcripts via chat tools.
- **Shared Context** (`features/context/`) -- Tenant-level corrections and learnings injected into agent system prompts.