> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deep.space/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](https://ai-sdk.dev/). Use the bundled `ChatPanel` component for a turnkey UI, or call the streaming endpoint directly and decode it with the [wire helpers](#custom-chat-ui).

## Install the chat feature

Install the bundled feature:

```bash theme={null}
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` - exports `aiChatSchemas` (an array of `[AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA]`) for spreading into `src/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:

```ts theme={null}
// 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:

```ts theme={null}
// 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` |

<Warning>
  **`create: false` is intentional.** Direct WebSocket creates would let a user PUT a forged `role: 'assistant'` row that the next turn's history reads back as if it were a real LLM response. Don't relax to `'own'`. All writes flow through the worker's chat routes, which validate ownership.
</Warning>

## 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.

<Tabs>
  <Tab title="Create chat">
    ```http theme={null}
    POST /api/ai/chats
    Authorization: Bearer <jwt>
    Content-Type: application/json

    { "title": "Untitled" }
    ```

    Returns:

    ```json theme={null}
    { "chat": { "recordId": "chat_abc", "userId": "...", "title": "Untitled", ... } }
    ```

    Creates a chat row owned by the JWT subject. The `title` field is optional.
  </Tab>

  <Tab title="Rename chat">
    ```http theme={null}
    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.
  </Tab>

  <Tab title="Delete chat">
    ```http theme={null}
    DELETE /api/ai/chats/:id
    Authorization: Bearer <jwt>
    ```

    Deletes the chat row and cascade-deletes its `ai-messages` rows. Owner-checked.
  </Tab>

  <Tab title="Stream a turn">
    ```http theme={null}
    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).
  </Tab>
</Tabs>

## Streaming pipeline

The `POST /api/ai/chat` handler runs through these steps:

<Steps>
  <Step title="Verify the JWT">
    Reject anonymous callers with 401.
  </Step>

  <Step title="Look up the chat">
    Return 404 if the chat doesn't exist or belongs to another user.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Stream the model">
    Call `streamText` with the prepared messages, tools, and an abort signal tied to the request.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## Switch the model

`src/ai/chat-routes.ts` maps allowed model IDs to providers:

```ts theme={null}
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`:

```ts theme={null}
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                  |

<Note>
  **Per-collection RBAC at the DO is the security boundary.** The user's own role determines what each tool call can do - the assistant cannot escalate. To run a stricter assistant, trim `ALLOWED_TOOL_NAMES` to reads only.
</Note>

### Adding custom tools

Extend the `ToolSet` returned by `buildTools` in `src/ai/tools.ts`:

```ts theme={null}
// 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`:

```ts theme={null}
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`:

```ts theme={null}
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:

1. Truncate old tool-result payloads (preserves the most recent N intact).
2. Apply a cached summary if one exists.
3. If still over budget, summarize the older half of the conversation.
4. As a final fallback, apply a sliding window down to `minKept` messages.

## 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:

```tsx theme={null}
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`](/sdk-reference/client/storage):

```tsx theme={null}
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

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="`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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="`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.
  </Accordion>
</AccordionGroup>

## Next steps

* [Worker AI reference](/sdk-reference/worker/ai) - `createDeepSpaceAI`, compaction helpers, chat-history wrappers.
* [Server actions](/guides/server-actions) - privileged routes that bypass user RBAC.
* [External APIs](/guides/external-apis) - call LLMs and other services through `integration.post`.
