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
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: truereply automatically when mentioned or when a message is directed at them.
Participant Model
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:
type SerializedPart = UIMessagePart<UIDataTypes, UITools>;Chat Record
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
-
Creation --
createChat()server action creates a new chat record. If noagentIdis provided, the system resolves a default agent by searching for the tenant's configured primary-agent slug fromproduct.config.ts, then falling back to the matching system agent and finally any enabled agent. The action also stampschats.workspace_idfrom the active workspace URL (viagetActiveWorkspace()) so conversations are naturally scoped to the workspace where they started. -
Message Flow -- The user sends a message via the chat UI. The client uses
DefaultChatTransport(AI SDK v6) to stream messages to the/api/chatroute. The route resolves the agent, builds its tool set (filtered by user permissions), loads conversation history, and callsstreamText. -
Persistence --
saveChatMessage()persists each message with both a plaincontentstring (backward compatibility) and structuredparts. 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'supdated_attimestamp; the UPDATE now uses.select("id")and throws a descriptive error if Supabase returns a failure, rather than silently dropping the error. -
Agent Selection -- Users switch agents mid-conversation via
updateChat(), which updates theagent_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. -
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:
ModelSelectornow 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)-- ReconstructsUIMessageparts 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 typedChatUIMessagefor the AI SDK.collectMessagePartsFromFullStream(stream)-- Collects streaming chunks into finalizedcontentandpartsfor persistence. Tool parts are tracked via aMapkeyed bytoolCallId, 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-startmarkers,dynamic-toolentries, 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 aUIMessage, strips non-replayable parts, falls back to text content when no parts survive, then runs AI SDK'svalidateUIMessagesfor 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 UIs (registered via registerToolUI()) render rich output displays; tools without custom UI fall back to GenericToolOutput.
Helper functions for tool state inspection:
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-errorMarkdown 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 asnext/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:
- Tenant-specific agent with the configured primary-agent slug from
product.config.tsandenabled = true. - System-level agent with that same slug and
is_system = true. - Any enabled tenant or system agent returned by the fallback lookup.
- 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.
Agent System
DB-managed AI agents with configurable tool groups, scheduled heartbeat execution, external connections, delegation, config versioning, and a unified execution runtime.
Document Processing
End-to-end document lifecycle from upload through parsing, chunking, and embedding, with hybrid search, entity linking, signed URL access, and integration as extraction sources.