AI chat
Streamed multi-turn chat with Claude, GPT, and Cerebras - tool use, persistent history, and context compaction included.
Stream multi-turn chat with persistent history and built-in tool use over your records. The scaffold registers four HTTP endpoints, persists every chat to a Durable Object, and streams responses via Vercel AI SDK v5. Use the bundled ChatPanel component for a turnkey UI, or call the streaming endpoint directly and decode it with the wire helpers.
Install the chat feature#
Install the bundled feature:
npx deepspace add ai-chat
This installs three files:
src/components/ChatPanel.tsx- a reusable chat surface with model picker, abort button, and Markdown rendering.src/pages/assistant.tsx- a full-page assistant with a chat history rail.src/schemas/ai-chat-schema.ts- exportsaiChatSchemas(an array of[AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA]) for spreading intosrc/schemas.ts.
The feature also adds react-markdown, remark-gfm, remark-breaks, rehype-highlight, and highlight.js as dependencies and runs npm install.
Add the schemas#
If you're wiring chat by hand, import the two pre-built schemas directly:
// src/schemas.ts
import { AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA } from 'deepspace/worker'
export const schemas = [
usersSchema,
settingsSchema,
AI_CHATS_SCHEMA,
AI_MESSAGES_SCHEMA,
// ...your collections
]
Or, if you ran npx deepspace add ai-chat, spread the array the feature installed:
// src/schemas.ts
import { aiChatSchemas } from './schemas/ai-chat-schema'
export const schemas = [usersSchema, settingsSchema, ...aiChatSchemas]
| Schema | Rows | RBAC |
|---|---|---|
AI_CHATS_SCHEMA (ai-chats) |
One per chat conversation | read/update/delete: 'own', create: false |
AI_MESSAGES_SCHEMA (ai-messages) |
One per message (user or assistant) | read/update/delete: 'own', create: false |
The four chat endpoints#
The scaffold registers four endpoints in src/ai/chat-routes.ts. The first three manage chat records; the fourth streams a turn.
POST /api/ai/chats
Authorization: Bearer <jwt>
Content-Type: application/json
{ "title": "Untitled" }
Returns:
{ "chat": { "recordId": "chat_abc", "userId": "...", "title": "Untitled", ... } }
Creates a chat row owned by the JWT subject. The title field is optional.
PATCH /api/ai/chats/:id
Authorization: Bearer <jwt>
Content-Type: application/json
{ "title": "Renamed" }
Owner-checked. Returns 404 if the chat doesn't exist or belongs to another user.
DELETE /api/ai/chats/:id
Authorization: Bearer <jwt>
Deletes the chat row and cascade-deletes its ai-messages rows. Owner-checked.
POST /api/ai/chat
Authorization: Bearer <jwt>
Content-Type: application/json
{ "chatId": "chat_abc", "userMessageId": "umsg_xyz", "content": "Hello", "modelId": "claude-sonnet-4-6" }
Returns text/event-stream of Vercel AI SDK v5 UIMessageChunk events. The X-Asst-Id response header carries the assistant row's ID for client-side dedup.
For decoding the stream in custom UIs, see [Custom chat UI](#custom-chat-ui).
Streaming pipeline#
The POST /api/ai/chat handler runs through these steps:
Verify the JWT#
Reject anonymous callers with 401.
Look up the chat#
Return 404 if the chat doesn't exist or belongs to another user.
Load history without persisting the new message#
The new user turn is appended in memory only - both user and assistant rows persist together inside onFinish so a stream error leaves zero orphan rows.
Prepare messages with compaction#
Truncate old tool results, apply a cached summary if one exists, and summarize the older half of the conversation if still over the context budget.
Stream the model#
Call streamText with the prepared messages, tools, and an abort signal tied to the request.
Persist on completion#
Write user → assistant → metadata rows in that order, with retry. If the user write fails twice, the assistant write is skipped to keep history paired.
Switch the model#
src/ai/chat-routes.ts maps allowed model IDs to providers:
const ALLOWED_MODELS: Record<string, 'anthropic' | 'openai' | 'cerebras'> = {
'claude-opus-4-7': 'anthropic',
'claude-sonnet-4-6': 'anthropic',
'claude-haiku-4-5': 'anthropic',
'gpt-5.4': 'openai',
'gpt-5.4-mini': 'openai',
'gpt-5.4-nano': 'openai',
'gpt-oss-120b': 'cerebras',
}
const DEFAULT_MODEL = 'claude-sonnet-4-6'
Unknown modelId values are rejected with 400 - there is no silent fallback.
Provider routing happens via createDeepSpaceAI:
import { createDeepSpaceAI } from 'deepspace/worker'
const provider = createDeepSpaceAI(env, 'anthropic', { authToken: jwt })
authToken |
Who pays |
|---|---|
| Passed | The caller (signed-in user) - billed against their DeepSpace credits |
| Omitted | The app owner - billed via APP_OWNER_JWT |
The scaffold's chat routes pass the caller's JWT, so each user pays for their own conversation. Omit authToken for autonomous server-side calls (cron, server actions).
Tool use#
The assistant can read and modify your records via a built-in tool catalog. The scaffold ships all of them in src/ai/tools.ts:
| Tool | Purpose |
|---|---|
schema.list |
Enumerate collection names |
schema.describe |
Describe one collection's columns and permissions |
records.query |
Filter and list records |
records.get |
Fetch one record |
records.create |
Create a record |
records.update |
Patch a record |
records.delete |
Delete a record |
user.current |
Look up the caller's user record |
Adding custom tools#
Extend the ToolSet returned by buildTools in src/ai/tools.ts:
// src/ai/tools.ts
import { tool, type ToolSet } from 'ai'
import { z } from 'zod'
import { BUILT_IN_TOOLS } from 'deepspace/worker'
export function buildTools(executor: ToolExecutor): ToolSet {
const tools: ToolSet = {}
// ...existing loop over BUILT_IN_TOOLS...
tools.lookup_weather = tool({
description: 'Get weather for a city',
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => {
const res = await fetch(`https://api.example.com/weather?city=${encodeURIComponent(city)}`)
if (!res.ok) return { error: `weather lookup failed: ${res.status}` }
return await res.json()
},
})
return tools
}
The Zod inputSchema doubles as runtime validation; failing input emits a tool-input-error SSE chunk the client surfaces.
Context compaction#
For long conversations, the scaffold automatically compacts older turns to stay under the model's context budget. The default config exported from deepspace/worker:
import { DEFAULT_CONTEXT_CONFIG } from 'deepspace/worker'
// {
// contextBudget: 240_000, // chars - ≈60–80K tokens
// toolResultCap: 30_000, // bytes per tool result
// keepRecentToolResults: 5,
// minKept: 10, // sliding-window floor
// }
Tune for shorter-context models by passing your own config to prepareMessagesWithCompaction in chat-routes.ts:
import { DEFAULT_CONTEXT_CONFIG, prepareMessagesWithCompaction } from 'deepspace/worker'
const config = {
...DEFAULT_CONTEXT_CONFIG,
contextBudget: 120_000, // for 128K-context models
}
const { messages: prepared, newSummary } = await prepareMessagesWithCompaction(
turns,
config,
{ summarizer, cachedSummary },
)
The pipeline:
- Truncate old tool-result payloads (preserves the most recent N intact).
- Apply a cached summary if one exists.
- If still over budget, summarize the older half of the conversation.
- As a final fallback, apply a sliding window down to
minKeptmessages.
Custom chat UI#
If you want to build your own chat surface (sidebar, modal, minimal), call POST /api/ai/chat directly and decode the SSE stream with the SDK's wire helpers:
import { parseSseLine, decodeAiStreamChunk, getAuthToken, type AiStreamAction } from 'deepspace'
async function streamTurn(chatId: string, content: string, handleAction: (asstId: string, action: AiStreamAction) => void) {
const token = await getAuthToken()
const res = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ chatId, userMessageId: crypto.randomUUID(), content }),
})
const asstId = res.headers.get('X-Asst-Id')!
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = 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) {
const chunk = parseSseLine(line)
if (!chunk) continue
const action = decodeAiStreamChunk(chunk)
if (action) handleAction(asstId, action)
}
}
}
Action vocabulary#
decodeAiStreamChunk returns one of:
| Action | When it fires |
|---|---|
append-text |
Text-delta token from the model |
upsert-tool-call |
A tool invocation started |
finalize-tool-call |
A tool returned its result |
fail-tool-input |
The tool's Zod schema rejected the input |
fail-tool-output |
The tool's execute threw |
stream-error |
Top-level stream error |
abort |
Server-side abort with no error chunk to follow |
For the canonical message list, query ai-messages from inside a RecordScope:
import { useQuery } from 'deepspace'
type AiMessageData = {
chatId: string
userId: string
role: 'user' | 'assistant'
content: string
parts?: unknown[]
}
const { records } = useQuery<AiMessageData>('ai-messages', {
where: { chatId, userId },
orderBy: 'createdAt',
orderDir: 'asc',
})
// Each record is { recordId, data, createdAt, updatedAt } - fields live on `.data`.
records.map((r) => ({ id: r.recordId, role: r.data.role, content: r.data.content }))
The parts field on each data holds UI-shape tool invocations for rendering.
Limitations#
Concurrent multi-tab writes can interleave
The Durable Object serializes individual writes, but not the per-request 3-write group (user → assistant → metadata). Two tabs sending turns simultaneously to the same chatId can produce a non-strictly-paired history. Realistic impact is rare.
stopWhen: stepCountIs(5) caps tool loops
Each turn can chain up to 5 tool calls. Raise this in chat-routes.ts for agentic workflows that need more steps - each step is a full LLM round-trip with proportional cost.
Reasoning content is stripped
toUIMessageStreamResponse({ sendReasoning: false }) removes reasoning-* chunks. The default UI has no "thinking" disclosure block, so flipping this on without UI changes shows no progress indication during long reasoning steps.
X-Asst-Id header is required for dedup
The header lets the client tag in-flight overlays with a server-generated ID that survives clock skew. If you proxy the streaming response through another worker, preserve the header.
Next steps#
- Worker AI reference -
createDeepSpaceAI, compaction helpers, chat-history wrappers. - Server actions - privileged routes that bypass user RBAC.
- External APIs - call LLMs and other services through
integration.post.