# AI chat

> Chats, projects and a branching message tree in a canonical message format, with runs, tool approvals, share links, a model catalog per plan and moderation events.

Source: https://bettersupabase.com/docs/blocks/ai-chat

The `ai-chat` block stores the conversations of an AI assistant: chats and
projects per user and tenant, a message tree with edits and regenerations
as branches, the run that writes each answer, tool approvals, share links,
the models each plan allows and moderation events. It doesn't call a model.
The [AI SDK adapter](/docs/ai-sdk) generates the answers and stores them
here; another SDK can do the same through the same methods.

```bash
better-supabase sql add ai-chat   # adds tenant, access and streams as well
```

| Table                  | Holds                                                                                                |
| ---------------------- | ---------------------------------------------------------------------------------------------------- |
| `ai_projects`          | Folders of chats with shared instructions, per owner and tenant                                      |
| `ai_chats`             | `title`, `model`, `visibility`, `pinned`, `archived_at`, `temporary`, the leaf and the active stream |
| `ai_messages`          | The tree: `parent_id`, `role`, `parts` in the message format below, `native`, `status`               |
| `ai_runs`              | One row per answer: model, status, token usage, the gateway's generation id and cost, the engine     |
| `ai_run_steps`         | The progress of a long run, one row per step key                                                     |
| `ai_harness_sessions`  | What a coding-agent harness keeps per chat: resume state, continue state and a lock (experimental)   |
| `ai_sandboxes`         | Every sandbox or container a chat or harness started: provider, id, status, idle seconds, last use   |
| `ai_tool_approvals`    | Approval requests for tool calls and the decision                                                    |
| `ai_tool_policies`     | `auto`, `ask` or `deny` per tool and tenant                                                          |
| `ai_pending_inputs`    | Questions a run asks the user, and the answer                                                        |
| `ai_message_sources`   | Cited sources per message                                                                            |
| `ai_message_feedback`  | Thumbs up or down per message and user                                                               |
| `ai_chat_shares`       | Share links, stored as the SHA-256 of the token                                                      |
| `ai_model_catalog`     | The models the app offers, with pricing and the plans that may use each                              |
| `ai_moderation_events` | Flags, redactions and blocks with their category and score                                           |

| Permission         | Lets a member                                        | Default roles              |
| ------------------ | ---------------------------------------------------- | -------------------------- |
| `ai_chat.read`     | read the tenant's chats shared with the organization | `owner`, `admin`, `member` |
| `ai_chat.create`   | start chats in the tenant                            | `owner`, `admin`, `member` |
| `ai_chat.share`    | share a chat with the organization or by link        | `owner`, `admin`, `member` |
| `ai_chat.moderate` | read the tenant's moderation events                  | `owner`, `admin`           |
| `ai_chat.admin`    | set tool policies and delete any chat in the tenant  | `owner`, `admin`           |

Private chats are readable by their owner only. Assistant messages, runs,
approvals and the model catalog are written by the service role, so a
client can't forge an answer.

## Server [#server]

```ts title="lib/ai-chat.ts"
import { createAiChat, rpcTransport } from "better-supabase/blocks/ai-chat";

export const aiChatFor = (supabase: SupabaseClient, admin: SupabaseClient) =>
  createAiChat({
    transport: rpcTransport(supabase),
    service: rpcTransport(admin),
  });
```

`transport` calls as the user and `service` as the service role. With
`better-supabase/postgres`, pass `sqlTransport(postgres.asUser(claims))` and
`sqlTransport(postgres.admin)`.

| Group        | Methods                                                                                     |
| ------------ | ------------------------------------------------------------------------------------------- |
| `chats`      | `create`, `get`, `list`, `update`, `remove`, `purge` (expired temporary chats)              |
| `projects`   | `create`, `update`, `list`, `remove`                                                        |
| `messages`   | `appendUser`, `saveAssistant`, `path`, `siblings`, `switchBranch`                           |
| `runs`       | `claim`, `release`, `stop`, `setCost`, `get`, `list`, `attach`, `steps`, `pendingApprovals` |
| `sandboxes`  | `register`, `touch`, `forChat`, `list`, `idle`, `finishStop`, `stopIdle`, `idleStopJob`     |
| `approvals`  | `request`, `decide`, `list`, `setPolicy`, `policies`                                        |
| `inputs`     | `open`, `answer`                                                                            |
| `feedback`   | `rate`                                                                                      |
| `shares`     | `create`, `revoke`, `list`, `get` (by token, for anyone who has it)                         |
| `models`     | `allowed` (what the tenant's plans allow), `upsert`                                         |
| `moderation` | `record`, `list`                                                                            |

Every method returns a `Result`. Database errors are `DbError` values, never
exceptions.

## Branches [#branches]

`appendUser` stores the user's message under the chat's leaf. Sending the
same id again is a no-op, and the same id with other parts is an edit,
stored as a sibling. With `trigger: "regenerate-message"` nothing is stored
and the leaf moves back, so the next answer becomes a sibling of the old
one. `path(chatId)` returns the active branch from the root, `siblings`
the alternatives at one message, and `switchBranch` shows another branch.

## Runs and streams [#runs-and-streams]

One answer runs at a time per chat. `runs.claim(chatId, streamId)` is a
compare-and-set on the chat: it fails while another stream is active,
unless that one is older than `staleAfter`. The writer appends to the
[durable stream](/docs/blocks/streams), so a reload resumes the answer, and
`runs.release` ends the run with its status and usage. `runs.stop` sets the
stream's cancel flag; the writer sees it on its next append.

A run is `queued`, `running`, `cancel_requested`, `completed`, `failed` or
`cancelled`. AI tasks and workflow runs use the same names, and
`FINAL_RUN_STATES` from `better-supabase/blocks` lists the three final
ones.

## Durable runs [#durable-runs]

A run's `engine` is `ai-sdk` for an answer written inside the request and
`workflow` for one that runs as a workflow
([durable chat](/docs/ai-sdk/workflow)), with the engine's own run id in
`external_run_id`. `runs` on the chat client reads them:

```ts
const { runs } = createAiChat({ transport, service: serviceTransport });
const mine = await runs.list({ active: true }).orThrow();
const steps = await runs.steps.list(mine[0].id).orThrow();
const inbox = await runs.pendingApprovals().orThrow();
```

| Method                       | Does                                                                  |
| ---------------------------- | --------------------------------------------------------------------- |
| `get`, `list`                | The caller's runs, or one chat's (`chatId`), newest first             |
| `attach`                     | Records the engine's run id (service role)                            |
| `steps.record`, `steps.list` | Records a step by its key (service role); the same key updates it     |
| `pendingApprovals`           | The caller's undecided approvals across chats, with each chat's title |

Members read the runs and steps of the chats they can read; only the
service role writes them.

### Harness sessions [#harness-sessions]

A coding-agent harness keeps its state per chat in `ai_harness_sessions`
through `createHarnessSessions`, on the service role. The block stores the
harness's resume and continue states as JSON without reading them. `lock`
keeps two turns from driving one sandbox for `ttlSeconds`, and `save` with a
`holder` fails with `AI_HARNESS_LOCKED` while another holder has the lock.
Members can't read the table, not even for their own chats, because resume
state can hold harness credentials. The store is experimental: its shape
follows the harnesses as they settle.

`save` with a `sandboxId` registers the session's sandbox in
`ai_sandboxes` (provider `harness` unless `sandboxProvider` names one), and
`sandboxId: null` marks it stopped. `load` returns the running sandbox's id,
so a harness reads and writes `sandboxId` as before. The
[idle-stop job](#sandboxes) stops harness sandboxes with the rest, skips one
whose session a turn holds the lock of, and marks the session `stopped` once
its last sandbox stopped.

## Sandboxes [#sandboxes]

`ai_sandboxes` holds every sandbox or provider container a chat or a harness
session started, so one idle-stop job stops all of them. Register each
sandbox the app starts, and mark it used while it runs:

```ts
const { sandboxes } = createAiChat({ transport, service: serviceTransport });
const row = await sandboxes
  .register(organizationId, {
    provider: "vercel",
    sandboxId: sandbox.id,
    chatId,
  })
  .orThrow();
await sandboxes.touch(row.id);
```

`forChat(chatId, provider)` returns the chat's running sandbox so the next
message reuses it. A sandbox started in another tenant is not reused:
`register` fails with the hint `AI_SANDBOX_FORBIDDEN`. Schedule the idle-stop
job every few minutes; it claims sandboxes that nobody used for their
`idleSeconds`, or that passed `expiresAt`, and calls your stopper:

```ts title="jobs.ts"
import { Sandbox } from "@vercel/sandbox";

export const handlers = {
  ai_sandbox_idle: sandboxes.idleStopJob((sandbox) =>
    Sandbox.get({ sandboxId: sandbox.sandboxId }).then((s) => s.stop()),
  ),
};
```

A harness sandbox reaches the stopper with its `harnessId` set. A stopper
that throws leaves the sandbox running with the error on the row, and the
next run tries again.

| Option             | Default | Sets                                                         |
| ------------------ | ------- | ------------------------------------------------------------ |
| `sandboxIdleAfter` | 600     | Seconds without use before the idle-stop job stops a sandbox |

## Share links [#share-links]

`shares.create(chatId)` returns a token once and stores only its hash. The
link shows the branch it was made from, frozen at that message.
`shares.get(token)` reads it without signing in; `revoke` ends it.

## Client [#client]

```tsx title="app/chats/sidebar.tsx"
"use client";

import { useAiChats } from "better-supabase/blocks/ai-chat/react";

export function Sidebar({ organizationId }: { organizationId: string }) {
  const { items, hasMore, loadMore, create } = useAiChats({ organizationId });
  // ...
}
```

| Hook                          | Returns                                                                       |
| ----------------------------- | ----------------------------------------------------------------------------- |
| `useAiChats(query)`           | The user's chats, newest first, with `loadMore`, `create`, `update`, `remove` |
| `useAiChatTree(chatId)`       | The active branch, with `switchBranch` and `step` between siblings            |
| `useAiModels(organizationId)` | The models the tenant may use, for a model picker                             |
| `useAiShare(chatId)`          | The chat's share links, with `create` and `revoke`                            |

The list and the tree load again when the private Realtime topics
`ai-chats:{userId}` and `ai-chat:{chatId}` announce a change
(`sql.modules["ai-chat"].options.listTopic` and `topic`). In Server
Components the hooks throw; call the block on the server instead.

## Message format [#message-format]

`better-supabase/blocks/ai-chat` defines the message format the AI blocks
store. A message has an `id`, a `role` (`system`, `user`, `assistant` or
`tool`), a list of `parts` and optional `metadata`. The format belongs to
better-supabase, not to one SDK: an adapter for the AI SDK or another SDK
converts its own messages to these parts and back, so the stored history,
search and memory work the same whichever SDK wrote them.

```ts
import {
  aiMessageSchema,
  type AiMessage,
} from "better-supabase/blocks/ai-chat";

const message: AiMessage = {
  id: "msg_1",
  role: "assistant",
  parts: [
    { type: "text", text: "Here is the invoice." },
    {
      type: "tool-call",
      toolCallId: "call_1",
      toolName: "getInvoice",
      input: { id: 42 },
    },
  ],
};

const result = await aiMessageSchema["~standard"].validate(message);
```

`aiMessageSchema` is a [Standard Schema](https://standardschema.dev), so it
works anywhere a Zod or Valibot schema does. `isAiMessage(value)` is the
type guard. An adapter may also keep its own copy of a message in `native`
(the AI SDK adapter stores the `UIMessage`), and reads it back with
`path(chatId, { native: true })`.

### Parts [#parts]

| Type            | Fields                                                                              |
| --------------- | ----------------------------------------------------------------------------------- |
| `text`          | `text`, `state` (`streaming` or `done`)                                             |
| `reasoning`     | `text`, `state`                                                                     |
| `file`          | `mediaType`, `url`, `filename`                                                      |
| `tool-call`     | `toolCallId`, `toolName`, `input`, `providerExecuted`                               |
| `tool-result`   | `toolCallId`, `toolName`, `output`, `isError`                                       |
| `tool-approval` | `toolCallId`, `approvalId`, `state` (`requested`, `approved` or `denied`), `reason` |
| `source`        | `sourceType` (`url` or `document`), `id`, `url`, `title`, `mediaType`               |
| `data`          | `name`, `id`, `data`                                                                |
| `step`          | `boundary` (`start` or `finish`), `stepId`                                          |

Every part can carry `providerMetadata`, an object the adapter passes
through unchanged. Unknown fields are rejected. An SDK part with no
counterpart becomes a `data` part, so a conversion never drops content.

### JSON Schema [#json-schema]

The same format is published as JSON Schema 2020-12 at
`https://unpkg.com/better-supabase/schemas/ai-message-v1.json`
(`AI_MESSAGE_SCHEMA_ID`). Use it to validate messages outside TypeScript or
in a `jsonb` check constraint. The version is pinned as
`SPEC_PINS.aiMessage`; a change to the format ships as a new schema file and
a new pin, never as an edit to version 1. See [Standards](/docs/standards).