Assistant
A chat route over the AI chat block with createAssistant, and useAssistant on the client, with resumable answers, stop, model checks, moderation and quotas.
createAssistant is the server half of a chat: it stores the user's
message in the AI chat block, checks the model,
the moderation hook and the quota, claims the chat's run, streams the
answer in the AI SDK's UI message stream protocol and stores the answer
with its usage when it ends. The answer also goes to a
stream store, so a reload or a second tab picks it
up where it is. useAssistant is the client half.
Server
import { streamText } from "ai";
import { createAssistant } from "better-supabase/ai-sdk/chat";
import { usageQuota } from "better-supabase/ai-sdk";
import { postgresStreamStore, sqlTransport } from "better-supabase/streams";
export const assistant = createAssistant({
streams: postgresStreamStore({
transport: sqlTransport(postgres.asService()),
}),
defaultModel: "anthropic/claude-sonnet-4.5",
quota: usageQuota(usage, "ai.input_tokens"),
run: ({ model, messages, abortSignal, providerOptions }) =>
streamText({
model,
system: "You are the Acme assistant.",
messages,
abortSignal,
providerOptions,
}),
enqueueCostBackfill: (payload) => jobs.enqueue("ai.cost_backfill", payload),
});run gets the model the request may use, the history as model messages,
the abort signal stop triggers and the gateway options
for the user, tenant and chat. Return streamText(...) or
agent.stream(...).
Three routes serve it. Each builds the per-request context: the chat block with the user's transport and a service transport, the user and the tenant.
import { after } from "next/server";
export async function POST(request: Request) {
const context = await assistantContext(); // { chats, userId, organizationId }
return assistant.respond(request, { ...context, waitUntil: after });
}export async function GET(
request: Request,
{ params }: RouteContext<"/api/chat/[id]/stream">,
) {
const { id } = await params;
return assistant.resume(id, await assistantContext(), {
signal: request.signal,
});
}export async function POST(
_: Request,
{ params }: RouteContext<"/api/chat/[id]/stop">,
) {
const { id } = await params;
return assistant.stop(id, await assistantContext());
}waitUntil keeps the function running until the answer is stored after the
response is sent: after in Next.js, ctx.waitUntil on Workers.
What a request does
- Reads
{ id, message, trigger, messageId, model }. The chat is created on first use with the id the client made. - Picks the model: the requested one when the tenant's plans allow it
(
403 AI_MODEL_NOT_ALLOWEDotherwise), then the chat's model, thendefaultModel, then the first allowed model. - Runs
moderateon the user's message, when set.blockanswers403 AI_MODERATION_BLOCKED; every verdict other thanallowis recorded. - Runs
quota, when set. An error answers 429 withRetry-After. - Stores the message (
appendUser), reads the active branch and repairs tool calls that never got a result. - Claims the run. While another answer runs it answers
409 AI_CHAT_BUSY. - Streams the answer. When it ends, the answer is stored (
complete, orabortedafter a stop), the run is released with its token usage and the gateway's generation id and cost, andonFinishruns.
Errors are RFC 9457 problem details responses with a code (see Results).
| Option | Does |
|---|---|
streams | The stream store: postgresStreamStore or redisStreamStore |
run | Starts the generation |
defaultModel | The model when the request and the chat name none that is allowed |
quota | The quota check, such as usageQuota(usage, meter) |
moderate | Checks the user's message: allow, flag, redact or block |
enqueueCostBackfill | Called when the gateway hasn't reported the cost yet |
onFinish | Called after the answer is stored, with its status and usage |
streamTtl | How long the stream is kept, seconds or an interval; one day by default |
feature | The gateway's feature: tag, chat by default |
onError | The message the client sees for a stream error |
generateId | Message and stream ids, crypto.randomUUID() by default |
Client
"use client";
import { useAssistant } from "better-supabase/ai-sdk/react";
import type { UIMessage } from "ai";
export function Chat({ id, initial }: { id: string; initial: UIMessage[] }) {
const { messages, sendMessage, stop, status, regenerate } = useAssistant({
id,
messages: initial,
model: "anthropic/claude-sonnet-4.5",
});
// ...
}useAssistant is useChat with a transport for these routes. It sends
only the new message, since the server keeps the history, reconnects to
/api/chat/{id}/stream after a reload (resume, on by default) and makes
stop() also stop the answer on the server, so it is stored as stopped.
api, body, headers, onError, onFinish and throttle work as in
useChat.
Load the history in a Server Component and pass it as messages:
import { toUIMessages } from "better-supabase/ai-sdk";
const path = await aiChat.messages.path(id, { native: true }).orThrow();
return <Chat id={id} initial={toUIMessages(path)} />;The UI message stream protocol version is pinned as
SPEC_PINS.aiSdkUiMessageStream; see Standards.
Example
The Next.js example has an assistant at /assistant
(apps/examples/nextjs/src/features/assistant). Without AI_GATEWAY_API_KEY
it answers with a scripted MockLanguageModelV4, so the stored messages,
the stream store and resuming after a reload work on the local stack.
Last updated on
AI SDK
Convert between AI SDK UI messages and the canonical message format, attribute spend in the AI Gateway, and keep the model catalog and costs current.
Durable chat
Answers that run as Workflow SDK workflows with durableChat and durableTurn, tool approvals that wait for hours, stop through a hook, reconnects by chunk index and progress in ai_run_steps.