BTST

AI Chat Plugin

AI-powered chat functionality with conversation history, streaming, sidebar navigation, and customizable models

Full-stackReleased · Preview

Best for

React teams that already know which AI SDK model provider they want and need chat history, tools, and product UI inside their app.

Add a streaming conversation surface while choosing the model, access policy, and operating limits yourself.

Real authenticated BTST AI Chat shell with an empty conversation-history sidebar, attachment control, and a product-context prompt ready to send.
The capture proves the shipped chat shell and prompt boundary; it deliberately does not fabricate a model response.

BTST supplies

  • Streaming chat APIs with typed tool, attachment, and lifecycle boundaries
  • Conversation and message models for authenticated history
  • SSR-aware conversation list and chat routes
  • Customizable chat pages, hooks, and prompt UI

You supply

  • An AI SDK model provider, credentials, usage policy, and provider billing
  • A database adapter with isolated transactions for authenticated history
  • Authorization rules for authenticated access, tools, and attachments
  • An upload implementation when file attachments are enabled

You own and customize

You select and pay the model provider. Authenticated history stays in your database, and ejected chat pages become editable application code while streaming and data behavior remain packaged.

Compatibility and dependencies

Maintained: Next.js 15+ App Router, React Router v7, TanStack Start.

Requires: An AI SDK language model; A database adapter with isolated transaction support for authenticated persistence.

External services: The adopter-selected AI model provider receives prompts and generates responses.

From registration to result

A semantic workflow, not a setup shortcut

  1. 1Choose a model

    Pass an AI SDK model and keep its credentials in your server environment.

  2. 2Set access

    Use authenticated persistence with typed rules or choose explicit stateless public mode.

  3. 3Stream

    Run prompts, tools, and optional attachments through the supplied chat route.

  4. 4Keep context

    Store identity-scoped conversations in your database when authenticated mode is enabled.

Installation

Ensure you followed the general framework installation guide first.

Follow these steps to add the AI Chat plugin to your BTST setup.

1. Add Plugin to Backend API

Import and register the AI Chat backend plugin in your stack.ts file:

lib/stack.ts
import { createBackendStack } from "@btst/stack/api"
import { defineAuthorization } from "@btst/stack/authorization"
import { createServerAuth } from "@btst/stack/authorization/server"
import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api"
import { aiChatPermissions } from "@btst/stack/plugins/ai-chat/permissions"
import { openai } from "@ai-sdk/openai"
import { z } from "zod"
// ... your adapter imports

// Browser-safe: put this in a shared authorization.ts module when the
// frontend and backend live in the same codebase.
export const authorization = defineAuthorization({
  identity: z.object({
    id: z.string(),
    role: z.enum(["user", "admin"]),
  }),
  permissions: [aiChatPermissions] as const,
  rules: ({ aiChat }) => {
    const owns = (
      identity: { id: string; role: "user" | "admin" } | null,
      ownerId?: string,
    ) => identity !== null && (identity.role === "admin" || identity.id === ownerId)
    const canStart = (
      identity: { id: string; role: "user" | "admin" } | null,
      ownerId?: string,
    ) => identity !== null && (ownerId === undefined || owns(identity, ownerId))

    return [
      aiChat.conversation.read.when(({ identity, facts }) =>
        facts.scope === "collection" ? identity !== null : owns(identity, facts.ownerId),
      ),
      aiChat.conversation.create.when(({ identity }) => identity !== null),
      aiChat.conversation.update.when(({ identity, facts }) => owns(identity, facts.ownerId)),
      aiChat.conversation.delete.when(({ identity, facts }) => owns(identity, facts.ownerId)),
      aiChat.message.send.when(({ identity, facts }) =>
        facts.createsConversation ? identity !== null : owns(identity, facts.ownerId),
      ),
      aiChat.message.edit.when(({ identity, facts }) => owns(identity, facts.ownerId)),
      aiChat.message.retry.when(({ identity, facts }) => owns(identity, facts.ownerId)),
      aiChat.attachment.send.when(({ identity, facts }) => canStart(identity, facts.ownerId)),
      aiChat.tool.activate.when(({ identity, facts }) => canStart(identity, facts.ownerId)),
      aiChat.stream.start.when(({ identity, facts }) =>
        facts.createsConversation ? identity !== null : owns(identity, facts.ownerId),
      ),
    ]
  },
})

const serverAuth = createServerAuth({
  authorization,
  getIdentity: async ({ headers }) => {
    const token = headers.get("authorization")
    if (!token) return null
    const user = await verifyToken(token)
    return user ? { id: user.id, role: user.role } : null
  },
})

const { handler, dbSchema } = createBackendStack({
  basePath: "/api/data",
  auth: serverAuth,
  plugins: {
    aiChat: aiChatBackendPlugin({
      model: openai("gpt-4o"), // Or any LanguageModel from AI SDK
      access: "authorized", // Default; use "public" only intentionally
      systemPrompt: "You are a helpful assistant.", // Optional
      tools: {}, // Optional: AI SDK v5 tools
    })
  },
  adapter: (db) => createMemoryAdapter(db)({})
})

export { handler, dbSchema }

The aiChatBackendPlugin() accepts optional lifecycle hooks for domain behavior, logging, rate limits, and tool safety. Identity/role/ownership policy belongs in the typed rules above.

Authorized AI Chat streams persist conversation and message history. Production database adapters must therefore provide real isolated transactions so the final authorization/CAS check, lifecycle hooks, and persistence commit as one unit. Set transaction: true on supported Prisma, Drizzle, and Kysely adapters; otherwise authorized streaming and owner-sensitive history mutations fail closed with ATOMIC_TRANSACTION_REQUIRED. The CLI enables this option when AI Chat is selected. The memory adapter remains available for local, single-process development, and explicit public mode does not persist history.

Model Configuration: You can use any model from the AI SDK, including OpenAI, Anthropic, Google, and more. Make sure to install the corresponding provider package (e.g., @ai-sdk/openai) and set up your API keys in environment variables.

2. Add Plugin to Client

Register the AI Chat client plugin in your stack-client.tsx file:

lib/stack-client.tsx
import { createClientStack } from "@btst/stack/client"
import { aiChatClientPlugin } from "@btst/stack/plugins/ai-chat/client"
import { QueryClient } from "@tanstack/react-query"

const getBaseURL = () => 
  typeof window !== 'undefined' 
    ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin)
    : (process.env.BASE_URL || "http://localhost:3000")

export const getStackClient = (
  queryClient: QueryClient,
  options?: { headers?: Headers; identity?: { id: string; role: "user" | "admin" } },
) => {
  const baseURL = getBaseURL()
  return createClientStack({
    api: {
      baseURL,
      basePath: "/api/data",
      ...(options?.headers ? { headers: options.headers } : {}),
    },
    site: { baseURL, basePath: "/pages" },
    queryClient,
    plugins: {
      aiChat: aiChatClientPlugin({
        identityPartition: options?.identity,
        // Client conversation UI/persistence mode
        mode: "authenticated", // "authenticated" (default) or "public"
        // Optional: SEO configuration
        seo: {
          siteName: "My Chat App",
          description: "AI-powered chat assistant",
        },
      })
    }
  })
}

The stack owns the shared API location, site location, request headers, and QueryClient. aiChatClientPlugin() accepts only AI Chat choices such as mode, SEO, loader hooks, page overrides, and the optional SSR identity partition.

Server request headers belong only on createClientStack({ api: { headers } }). For an intentionally public cross-origin AI Chat endpoint, use endpoints.aiChat.api with explicit browserHeaders and credentials; sensitive headers such as authorization and cookie are rejected from the browser projection.

Migrating to RC3: move apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, queryClient, and headers out of aiChatClientPlugin() and into the top-level client stack shown above. Rename the provider override key from "ai-chat" to aiChat, pass the resolved stack to StackProvider, remove the manual provider generic/override map, and rename the loader error hook from onLoadError to onErrorLoad. Configure mode only in aiChatClientPlugin(); the resolved stack carries it to browser components. The package path and /chat URL do not change.

3. Import Plugin CSS

Add the AI Chat plugin CSS to your global stylesheet:

app/globals.css
@import "@btst/stack/plugins/ai-chat/css";

This includes all necessary styles for the chat components and markdown rendering.

4. Add the Context Provider

Pass the resolved client stack to StackProvider; it supplies the API/site runtime and infers the exact aiChat override type. Keep only framework routing and AI Chat-specific values on the provider:

app/pages/client-layout.tsx
"use client"
import { useMemo } from "react"
import { StackProvider } from "@btst/stack/context"
import { nextRouter } from "@btst/stack/next"
import { QueryClientProvider } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient, type StackClientOptions } from "@/lib/stack-client"

export default function Layout({ children, clientOrigins }: {
  children: React.ReactNode
  clientOrigins: StackClientOptions
}) {
  const queryClient = getOrCreateQueryClient()
  const stack = useMemo(
    () => getStackClient(queryClient, clientOrigins),
    [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient],
  )
  
  return (
    <QueryClientProvider client={queryClient}>
      <StackProvider
        stack={stack}
        router={nextRouter()}
        overrides={{
          aiChat: {
            uploadFile: async (file) => {
              // Implement your file upload logic
              return "https://example.com/uploads/file.pdf"
            },
          }
        }}
      >
        {children}
      </StackProvider>
    </QueryClientProvider>
  )
}
app/routes/pages/_layout.tsx
import { useState } from "react"
import { Outlet } from "react-router"
import { StackProvider } from "@btst/stack/context"
import { reactRouter } from "@btst/stack/react-router"
import { QueryClientProvider, QueryClient } from "@tanstack/react-query"
import { getStackClient } from "~/lib/stack-client"

export default function Layout() {
  const [queryClient] = useState(() => new QueryClient())
  const stack = getStackClient(queryClient)
  
  return (
    <QueryClientProvider client={queryClient}>
      <StackProvider
        stack={stack}
        router={reactRouter()}
        overrides={{
          aiChat: {
            uploadFile: async (file) => {
              return "https://example.com/uploads/file.pdf"
            },
          }
        }}
      >
        <Outlet />
      </StackProvider>
    </QueryClientProvider>
  )
}
src/routes/pages/route.tsx
import { useState } from "react"
import { StackProvider } from "@btst/stack/context"
import { tanstackRouter } from "@btst/stack/tanstack"
import { QueryClientProvider, QueryClient } from "@tanstack/react-query"
import { Outlet } from "@tanstack/react-router"
import { getStackClient } from "@/lib/stack-client"

function Layout() {
  const [queryClient] = useState(() => new QueryClient())
  const stack = getStackClient(queryClient)

  return (
    <QueryClientProvider client={queryClient}>
      <StackProvider
        stack={stack}
        router={tanstackRouter()}
        overrides={{
          aiChat: {
            uploadFile: async (file) => {
              return "https://example.com/uploads/file.pdf"
            },
          }
        }}
      >
        <Outlet />
      </StackProvider>
    </QueryClientProvider>
  )
}

Optional overrides:

  • uploadFile: Function to upload files and return their URL
  • allowedFileTypes: Array of allowed file type categories (default: all types)
  • chatSuggestions: Array of suggested prompts shown in empty chat state
  • localization: Custom localization strings

5. Generate Database Schema

After adding the plugin, generate your database schema using the CLI:

npx @btst/cli generate --orm prisma --config lib/stack.ts --output prisma/schema.prisma

This will create the necessary database tables for conversations and messages. Run migrations as needed for your ORM.

For more details on the CLI and all available options, see the CLI documentation.

Congratulations, You're Done! 🎉

Your AI Chat plugin is now fully configured and ready to use! Here's a quick reference of what's available:

Access modes

The AI Chat plugin supports two distinct modes:

Authorized access (default)

  • Conversation persistence in database
  • Identity-scoped data from the request's validated server identity
  • Full UI with sidebar and conversation history
  • Routes: /chat (new/list) and /chat/:id (existing conversation)

Public Mode

  • No persistence (stateless)
  • Simple UI without sidebar
  • Ideal for public-facing chatbots
  • Single route: /chat

API Endpoints

The AI Chat plugin provides the following API endpoints, mounted at the resolved AI Chat API location. Configure that location with createClientStack({ endpoints: { aiChat: { api: { ... } } } }); otherwise it inherits the stack's top-level api location.

  • POST /chat - Send a message and receive streaming response
  • GET /chat/conversations - List all conversations (authenticated mode only)
  • GET /chat/conversations/:id - Get a conversation with messages
  • POST /chat/conversations - Create a new conversation
  • PUT /chat/conversations/:id - Rename a conversation; the title is trimmed and must not be empty
  • DELETE /chat/conversations/:id - Delete a conversation

In authorized mode, the streaming response includes X-Conversation-Id as soon as the backend has resolved or created the authoritative conversation. The built-in client uses that header to bind streamed message controls to persisted IDs even when the history-list refresh is delayed or fails. A separately deployed backend that implements the BTST contract must preserve this header; cross-origin deployments must also expose it through CORS.

Page Routes

The AI Chat plugin automatically creates the following pages, mounted at the resolved AI Chat site location. Configure that location with createClientStack({ endpoints: { aiChat: { site: { ... } } } }); otherwise it inherits the stack's top-level site location.

Authenticated mode:

  • /chat - Start a new conversation (with sidebar showing history)
  • /chat/:id - Resume an existing conversation

Public mode:

  • /chat - Simple chat interface without history

Features

  • Full-page Layout: Responsive chat interface with collapsible sidebar
  • Conversation Sidebar: View, rename, and delete past conversations
  • Streaming Responses: Real-time streaming of AI responses using AI SDK v5
  • Markdown Support: Full markdown rendering with code highlighting
  • File Uploads: Attach images, PDFs, and text files to messages
  • Tools Support: Use AI SDK v5 tools for function calling
  • Customizable Models: Use any LanguageModel from the AI SDK
  • Typed Authorization: Reuse the same schema-backed rules for browser hints and authoritative server checks
  • Localization: Customize all UI strings

Page Component Overrides

You can replace any built-in page with your own React component using the optional pageComponents field in aiChatClientPlugin(config). The built-in component is used as the fallback whenever an override is not provided. Overrides for parameterized routes receive the route context ({ params }) as props.

aiChatClientPlugin({
  // ... other config
  pageComponents: {
    // Replace the chat home page
    chat: MyCustomChatPage,
    // Replace the conversation page (authenticated mode only)
    // receives the route context as props
    chatConversation: ({ params }) => (
      <MyCustomConversationPage conversationId={params.id} />
    ),
  },
})

Adding Authorization

AI Chat publishes its browser-safe catalog from @btst/stack/plugins/ai-chat/permissions. Bind the same authorization definition to createClientAuth() for presentation and createServerAuth() for enforcement:

import { createClientAuth } from "@btst/stack/authorization/client"
import { authorization } from "./authorization"

const clientAuth = createClientAuth({
  authorization,
  getIdentity: () => session?.user ?? null,
  loginPath: "/sign-in",
})

<StackProvider
  stack={stack}
  auth={clientAuth}
  initialIdentity={initialIdentity}
  // ...
>
  {children}
</StackProvider>

The built-in route, new-chat, rename, delete, send, edit, retry, attachment, and tool controls construct exact descriptors from rendered conversation/message data. Those values are presentation hints only. Every backend operation reloads the authoritative conversation owner and message state before evaluating the same rule.

The streaming operation checks stream.start plus the exact semantic intent (message.send, message.edit, message.retry, attachment, tool, and conversation creation when applicable) before hooks, persistence, or provider work. A completed client-tool continuation requires both send and retry permission for its server-resolved user message, so a forged assistant transcript cannot bypass a denied retry rule. A coarse stream rule cannot bypass a denied sub-operation rule.

Attachment controls evaluate the selected file's real MIME type before calling the configured upload transport. The backend independently validates and authorizes the submitted file parts again before starting the provider.

Missing rules deny in authorized mode. Anonymous denials return 401 and identified denials return 403; identity, rule, schema, and fact-loading failures remain errors. BTST does not install an authorization-result cache.

The operation catalog also powers both server call styles:

await app.forRequest(request).operations.aiChat.deleteConversation({ id }) // authorized
await app.trusted.aiChat.deleteConversation({ id })                // trusted

trusted skips only user authorization. Input validation, authoritative reads, lifecycle hooks, persistence, and provider/tool behavior remain active. The raw getters documented below deliberately bypass authorization and lifecycle composition.

API Reference

Backend (@btst/stack/plugins/ai-chat/api)

aiChatBackendPlugin

Prop

Type

AiChatBackendConfig

The backend plugin accepts a configuration object with the model, explicit access policy, and optional hooks:

Prop

Type

AiChatBackendHooks

Customize post-authorization domain behavior with optional lifecycle hooks. Keep ordinary identity, role, owner, message, attachment, and tool policy in aiChatPermissions rules.

Prop

Type

AI Chat lifecycle names use the action-first onBefore<Action><Entity>, onAfter<Action><Entity>, and onError<Action><Entity> grammar. Chat completion remains the domain event onAfterChat.

Before RC3RC3
onBeforeToolsActivatedonBeforeActivateTools
onConversationsReadonAfterListConversations
onConversationReadonAfterGetConversation
onConversationCreatedonAfterCreateConversation
onConversationUpdatedonAfterUpdateConversation
onConversationDeletedonAfterDeleteConversation
onChatErroronErrorChat
onListConversationsErroronErrorListConversations
onGetConversationErroronErrorGetConversation
onCreateConversationErroronErrorCreateConversation
onUpdateConversationErroronErrorUpdateConversation
onDeleteConversationErroronErrorDeleteConversation

Example usage:

lib/stack.ts
import { aiChatBackendPlugin, type AiChatBackendHooks } from "@btst/stack/plugins/ai-chat/api"

const chatHooks: AiChatBackendHooks = {
  // Domain/abuse controls run only after typed authorization succeeds.
  onBeforeChat(messages, context) {
    enforceRateLimit(context.headers)
  },
  // Lifecycle hooks
  onAfterCreateConversation(conversation, context) {
    console.log("Conversation created:", conversation.id)
  },
  onAfterChat(conversationId, messages, context) {
    console.log("Chat completed:", conversationId, "messages:", messages.length)
  },
  // Error hooks
  onErrorChat(error, context) {
    console.error("Chat error:", error.message)
  },
}

const { handler, dbSchema } = createBackendStack({
  plugins: {
    aiChat: aiChatBackendPlugin({
      model: openai("gpt-4o"),
      hooks: chatHooks
    })
  },
  // ...
})

ChatApiContext

Prop

Type

Client (@btst/stack/plugins/ai-chat/client)

aiChatClientPlugin

Prop

Type

AiChatClientConfig

The client plugin accepts AI Chat-specific mode, SEO, loader hook, identity-partition, and page-component options. Shared runtime fields come from createClientStack():

mode has one source of truth: aiChatClientPlugin(). The resolved client stack carries it to both built-in routes and standalone AI Chat components, so do not repeat it in StackProvider.overrides.

Route-aware tool configuration stays at its established ownership seams: enable page tools and register tool schemas on aiChatBackendPlugin(), then register the active page's browser handlers with useRegisterPageAIContext({ clientTools }). Neither clientTools nor a pageTools option belongs on aiChatClientPlugin().

Prop

Type

Example usage:

lib/stack-client.tsx
aiChat: aiChatClientPlugin({
  // Mode configuration
  mode: "authenticated",
  // Optional SEO configuration
  seo: {
    siteName: "My AI Assistant",
    description: "Chat with our AI assistant",
    locale: "en_US",
    defaultImage: `${baseURL}/og-image.png`,
  },
})

AiChatClientHooks

Customize server-loader behavior with lifecycle hooks. The route loaders call these hooks while preloading data for SSR. Browser queries and mutations do not run them. onErrorLoad is a contained reporting hook: use it for logging or telemetry, not redirects, and do not rely on errors it throws escaping the loader.

Prop

Type

Example usage:

lib/stack-client.tsx
aiChat: aiChatClientPlugin({
  hooks: {
    beforeLoadConversations: async (context) => {
      console.log("Loading conversations for", context.path)
    },
    afterLoadConversation: async (conversation, id, context) => {
      // Log access for analytics
      console.log("User accessed conversation:", id)
    },
    onErrorLoad(error, context) {
      reportLoaderError(error, { path: context.path })
    },
  }
})

LoaderContext

Prop

Type

RouteContext

Prop

Type

AiChatPluginOverrides

Configure AI Chat-specific overrides and route lifecycle hooks. All lifecycle hooks are optional:

Prop

Type

Example usage:

overrides={{
  aiChat: {
    // Optional overrides
    uploadFile: async (file) => {
      const formData = new FormData()
      formData.append("file", file)
      const res = await fetch("/api/upload", { method: "POST", body: formData })
      const { url } = await res.json()
      return url
    },
    allowedFileTypes: ["image", "pdf", "text"], // Restrict allowed types
    // Suggested prompts shown in empty chat state
    chatSuggestions: [
      "What can you help me with?",
      "Tell me about your features",
      "How do I get started?",
    ],
    // Custom tool UI renderers (see "Custom Tool UI Renderers" section)
    toolRenderers: {
      getWeather: WeatherCard,
      searchDocs: SearchResultsRenderer,
    },
  }
}}

ChatLayout Component

The ChatLayout component provides a ready-to-use chat interface. It can be used directly for custom integrations or public mode with persistence:

ChatLayout always uses the mode registered by aiChatClientPlugin(); it does not accept a second component-level mode.

import { ChatLayout, type ChatLayoutProps, type UIMessage } from "@btst/stack/plugins/ai-chat/client"

ChatLayoutProps

Prop

Type

Widget layout — built-in trigger (default)

The default widget mode manages its own open/close state and renders a floating trigger button. Drop it anywhere in your layout and it just works:

<ChatLayout
  layout="widget"
  widgetHeight="520px"
/>

Widget layout — externally controlled (no trigger)

Use defaultOpen and showTrigger={false} when your own UI handles opening and closing — for example, a Next.js intercepting route modal or a custom dialog. The chat panel is immediately visible and the built-in trigger button is not rendered:

{/* Rendered inside a modal/dialog that you control */}
<ChatLayout
  layout="widget"
  widgetHeight="500px"
  defaultOpen={true}
  showTrigger={false}
/>

Next.js parallel-routes + intercepting-routes pattern — a common way to display the widget as a modal overlay while keeping a floating button on every page:

app/
  @chatWidget/
    default.tsx          ← floating button (Link to /chat)
    loading.tsx          ← loading overlay
    (.)chat/
      page.tsx           ← intercepting route: renders modal with ChatLayout
  chat/
    page.tsx             ← full-page fallback (hard nav / refresh)
  layout.tsx             ← passes chatWidget slot into the body
app/@chatWidget/default.tsx
"use client";
import Link from "next/link";
import { BotIcon } from "lucide-react";

export default function ChatWidgetButton() {
  return (
    <Link href="/chat" className="fixed bottom-6 right-6 z-50 ...">
      <BotIcon className="size-8" />
    </Link>
  );
}
app/@chatWidget/(.)chat/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { ChatLayout } from "@btst/stack/plugins/ai-chat/client";

export default function ChatModal() {
  const router = useRouter();
  return (
    {/* Backdrop */}
    <div className="fixed inset-0 z-50 bg-black/50" onClick={() => router.back()}>
      {/* Modal card */}
      <div className="..." onClick={(e) => e.stopPropagation()}>
        {/* This route remains below the app's existing StackProvider. */}
        <ChatLayout
          layout="widget"
          defaultOpen={true}
          showTrigger={false}
        />
      </div>
    </div>
  );
}

Example usage with localStorage persistence:

<ChatLayout
  layout="widget"
  widgetHeight="500px"
  initialMessages={savedMessages}
  onMessagesChange={(messages) => localStorage.setItem("chat", JSON.stringify(messages))}
/>

React Data Hooks and Types

You can import the hooks from "@btst/stack/plugins/ai-chat/client/hooks" to use in your components.

import {
  useConversations,
  useConversation,
  useSuspenseConversations,
  useSuspenseConversation,
  useCreateConversation,
  useRenameConversation,
  useRenameConversationForm,
  useDeleteConversation,
} from "@btst/stack/plugins/ai-chat/client/hooks"

UseConversationsOptions

Prop

Type

UseConversationsResult

Prop

Type

UseConversationOptions

Prop

Type

UseConversationResult

Prop

Type

UseRenameConversationFormOptions

Prop

Type

useRenameConversationForm() trims the submitted title, maps server validation issues to fieldErrors.title, sends success and non-field failures through the StackProvider notify provider, and preserves the conversation detail cache while refreshing the list.

Example usage:

import {
  useConversations,
  useConversation,
  useCreateConversation,
  useRenameConversation,
  useRenameConversationForm,
  useDeleteConversation,
} from "@btst/stack/plugins/ai-chat/client/hooks"

function ConversationsList() {
  // List all conversations
  const { conversations, isLoading, error, refetch } = useConversations()

  // Get single conversation with messages
  const { conversation } = useConversation(selectedId)

  // Mutations
  const createMutation = useCreateConversation()
  const renameMutation = useRenameConversation()
  const deleteMutation = useDeleteConversation()

  const handleCreate = async () => {
    const newConv = await createMutation.mutateAsync({ title: "New Chat" })
    // Navigate to new conversation
  }

  const handleRename = async (id: string, newTitle: string) => {
    await renameMutation.mutateAsync({ id, title: newTitle })
  }

  const handleDelete = async (id: string) => {
    await deleteMutation.mutateAsync({ id })
  }

  // ... render conversations
}

For a custom rename dialog, prefer the form lifecycle over calling the raw mutation directly:

const renameForm = useRenameConversationForm({
  conversation,
  onSuccess: () => setOpen(false),
})

await renameForm.submit({ title })

return renameForm.fieldErrors.title ? (
  <p role="alert">{renameForm.fieldErrors.title}</p>
) : null

Query keys and resource declaration

The server-safe query-key entry point exposes both the factory and the underlying declaration:

import {
  aiChatResources,
  createAiChatQueryKeys,
  type AiChatQueryKeys,
} from "@btst/stack/plugins/ai-chat/query-keys"

The stable prefixes remain ['conversations', 'list', 'all'] and ['conversations', 'detail', id]. Protected keys append the validated identity id plus an opaque fingerprint (or an explicit anonymous marker), never the full identity object. Structural claims are fingerprinted deterministically; non-JSON claims use conservative reference partitions because they cannot cross an SSR boundary. This keeps authorization-relevant identity changes in separate cache partitions without serializing those claims into dehydrated caches. Pending/error identity generations use separate disabled keys, mutations refresh only the partition that started them, and an account switch clears drafts, attachments, edit state, messages, and any active stream. Server loaders must pass identityPartition (the framework codegen does this automatically) so dehydrated data lands in the same browser partition.

Model & Tools Configuration

Using Different Models

lib/stack.ts
import { openai } from "@ai-sdk/openai"
import { anthropic } from "@ai-sdk/anthropic"
import { google } from "@ai-sdk/google"

// Use OpenAI
aiChat: aiChatBackendPlugin({
  model: openai("gpt-4o"),
})

// Or use Anthropic
aiChat: aiChatBackendPlugin({
  model: anthropic("claude-3-5-sonnet-20241022"),
})

// Or use Google
aiChat: aiChatBackendPlugin({
  model: google("gemini-1.5-pro"),
})

Adding Tools

Use AI SDK v5 tools for function calling:

lib/stack.ts
import { tool } from "ai"
import { z } from "zod"

const weatherTool = tool({
  description: "Get the current weather in a location",
  inputSchema: z.object({
    location: z.string().describe("The city and state"),
  }),
  execute: async ({ location }) => {
    // Your implementation
    return { temperature: 72, condition: "sunny" }
  },
})

aiChat: aiChatBackendPlugin({
  model: openai("gpt-4o"),
  tools: {
    getWeather: weatherTool,
  },
})

Custom Tool UI Renderers

By default, tool calls are displayed using a collapsible accordion that shows the tool name, status, input, and output. You can customize this UI by providing custom renderers for specific tools via the toolRenderers override.

Default Tool UI

The default ToolCallDisplay component shows:

  • Tool name with status indicator (loading spinner, checkmark, or error icon)
  • Collapsible accordion to inspect tool call details
  • Input arguments passed to the tool
  • Output returned by the tool (when complete)
  • Error message (if tool execution failed)

Custom Tool Renderers

Provide custom UI components for specific tools using the toolRenderers override. Each key should match the tool name from your backend configuration:

app/pages/client-layout.tsx
import type { ToolCallProps } from "@btst/stack/plugins/ai-chat/client"

// Custom weather card component
function WeatherCard({ input, output, isLoading }: ToolCallProps<{ location: string }, { temperature: number; condition: string }>) {
  if (isLoading) {
    return (
      <div className="p-4 border rounded-lg animate-pulse">
        <div className="h-4 w-24 bg-muted rounded" />
      </div>
    )
  }
  
  if (!output) return null
  
  return (
    <div className="p-4 border rounded-lg bg-gradient-to-r from-blue-50 to-blue-100">
      <h4 className="font-medium">{input?.location}</h4>
      <p className="text-2xl font-bold">{output.temperature}°F</p>
      <p className="text-muted-foreground">{output.condition}</p>
    </div>
  )
}

// In your layout
<StackProvider
  stack={stack}
  router={nextRouter()}
  overrides={{
    aiChat: {
      // Custom tool renderers
      toolRenderers: {
        getWeather: WeatherCard,
        searchDocs: ({ input, output, isLoading }) => (
          <SearchResultsCard query={input?.query} results={output} loading={isLoading} />
        ),
      },
    }
  }}
>
  {children}
</StackProvider>

ToolCallProps

Each custom renderer receives these props:

Prop

Type

ToolCallState

The possible states of a tool call:

Prop

Type

Using the Default ToolCallDisplay

You can also import and use the default ToolCallDisplay component in your custom renderers as a fallback:

import { ToolCallDisplay, type ToolCallProps } from "@btst/stack/plugins/ai-chat/client"

function MyCustomToolRenderer(props: ToolCallProps) {
  // Custom rendering for specific states
  if (props.state === "output-available" && props.output) {
    return <MyCustomOutput data={props.output} />
  }
  
  // Fall back to default display for other states
  return <ToolCallDisplay {...props} />
}

Public Mode Configuration

For public chatbots without user authentication:

Backend Setup

lib/stack.ts
import { createBackendStack } from "@btst/stack/api"
import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api"
import { openai } from "@ai-sdk/openai"

// Example rate limiter (implement your own)
const rateLimiter = new Map<string, number>()

const { handler, dbSchema } = createBackendStack({
  basePath: "/api/data",
  plugins: {
    aiChat: aiChatBackendPlugin({
      model: openai("gpt-4o"),
      access: "public", // Explicit, stateless streaming access
      systemPrompt: "You are a helpful customer support bot.",
      hooks: {
        onBeforeChat: async (messages, ctx) => {
          // Implement rate limiting
          const ip = ctx.headers?.get("x-forwarded-for") || "unknown"
          const requests = rateLimiter.get(ip) || 0
          if (requests > 10) {
            throw new Error("Rate limit exceeded")
          }
          rateLimiter.set(ip, requests + 1)
        },
      },
    })
  },
  adapter: (db) => createMemoryAdapter(db)({})
})

Client Setup

lib/stack-client.tsx
aiChat: aiChatClientPlugin({
  mode: "public", // Stateless public conversation UI
})

Provider Configuration

<StackProvider
  stack={stack}
  router={nextRouter()}
>
  {children}
</StackProvider>

Public access is explicit operation metadata, not a missing-rule fallback. It makes only the maintained streaming intents public: stream start, send/edit/retry semantics, validated attachments, and configured tools. Validation, rate/abuse hooks, provider limits, route/tool allowlists, and attachment safety still run. Conversation history and CRUD endpoints remain unavailable (404), no conversation is persisted, and the setting does not make any other plugin or application operation public.

Local Storage Persistence

By default, public mode is completely stateless - messages are lost on page refresh. However, you can persist conversations to localStorage (or any storage mechanism) using the initialMessages and onMessagesChange props on ChatLayout:

components/public-chat.tsx
"use client";

import { ChatLayout, type UIMessage } from "@btst/stack/plugins/ai-chat/client";
import { useLocalStorage } from "@/hooks/useLocalStorage"; // Your hook

export default function PublicChat() {
  const [messages, setMessages] = useLocalStorage<UIMessage[]>(
    "public-chat-messages",
    []
  );

  return (
    <ChatLayout
      layout="widget"
      initialMessages={messages}
      onMessagesChange={setMessages}
    />
  );
}

SSR Hydration: When using localStorage with SSR frameworks, ensure you handle hydration correctly to avoid mismatches. The initialMessages prop is applied on mount, so it works well with client-side storage hooks that return an empty array during SSR.

Key points:

  • initialMessages - Pre-populates the chat with saved messages on mount
  • onMessagesChange - Called whenever messages change (only fires in public mode)
  • UIMessage type is re-exported from @btst/stack/plugins/ai-chat/client for convenience

This pattern enables:

  • localStorage - Simple browser-based persistence
  • sessionStorage - Per-tab conversation history
  • IndexedDB - Larger storage for long conversations
  • External state management - Redux, Zustand, etc.

Localization and notifications

All rendered AI Chat copy is routed through the StackProvider i18n provider with aiChat.<area>.<name> keys. The legacy localization override remains supported and takes precedence when both are configured:

overrides={{
  aiChat: {
    // ... other overrides
    localization: {
      CHAT_PLACEHOLDER: "Ask me anything...",
      CHAT_EMPTY_STATE: "How can I help you today?",
      SIDEBAR_NEW_CHAT: "Start new conversation",
      CONVERSATION_DELETE_CONFIRM_TITLE: "Delete this chat?",
      // See AiChatLocalization type for all available strings
    }
  }
}}

AiChatLocalization

Prop

Type

Rename, delete, and file-upload feedback uses the shared notify provider. Field validation and streaming errors remain inline:

<StackProvider
  stack={stack}
  notify={{
    success: (message) => myToast.success(message),
    error: (message) => myToast.error(message),
  }}
  // ...
>
  {children}
</StackProvider>

Server-side Data Access

AI Chat has no raw stack.raw.aiChat business namespace. Use app.forRequest(request).operations.aiChat for request-driven work and app.trusted.aiChat for explicitly trusted jobs. Standalone conversation getters remain lower-level adapter primitives for plugin internals and migrations.

Route-Aware AI Context

The AI chat plugin supports route-aware context — pages register contextual data and client-side tool handlers that the chat widget reads automatically. This enables:

  • The AI to summarize content from the current page
  • The AI to fill in forms or update editors on the user's behalf
  • Dynamic suggestion chips that change based on which page is open

Setup

Step 1 — Add PageAIContextProvider to your root layout (above all StackProvider instances):

app/layout.tsx
import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context"

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <PageAIContextProvider>
          {/* Everything else, including StackProvider and your chat modal */}
          {children}
        </PageAIContextProvider>
      </body>
    </html>
  )
}

Place PageAIContextProvider above any StackProvider so it spans both the main app tree and any chat modals rendered as Next.js parallel/intercept routes. Both trees need to be descendants of the same context instance for context to flow between them.

Step 2 — Enable page tools in your backend config:

lib/stack.ts
aiChatBackendPlugin({
  model: openai("gpt-4o"),
  enablePageTools: true, // activates built-in fillBlogForm, updatePageLayers tools
})

Registering Page Context

Call useRegisterPageAIContext in any page component to publish context to the chat. The registration is cleaned up automatically when the component unmounts.

import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"

// Blog post page — provides content for summarization
function BlogPostPage({ post }) {
  useRegisterPageAIContext(post ? {
    routeName: "blog-post",
    pageDescription: `Blog post: "${post.title}"\n\n${post.content.slice(0, 16000)}`,
    suggestions: ["Summarize this post", "What are the key takeaways?"],
  } : null)

  // ...
}

Pass null to conditionally disable context (e.g. while data is loading).

Client-Side Tools

Pages can expose client-side tool handlers — functions the AI can call to mutate page state. Built-in tools (fillBlogForm, updatePageLayers) are already wired up in the blog and UI builder plugins. For custom pages:

1. Register a tool handler on the page:

import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"

function ProductPage({ product, cart }) {
  useRegisterPageAIContext({
    routeName: "product-detail",
    pageDescription: `Product: ${product.name}. Price: $${product.price}.`,
    suggestions: ["Tell me about this product", "Add to cart"],
    clientTools: {
      addToCart: async ({ quantity }) => {
        cart.add(product.id, quantity)
        return { success: true, message: `Added ${quantity} to cart` }
      }
    }
  })
}

2. Register the tool schema server-side (so the LLM knows the parameter shapes):

lib/stack.ts
import { tool } from "ai"
import { z } from "zod"

aiChatBackendPlugin({
  model: openai("gpt-4o"),
  enablePageTools: true,
  clientToolSchemas: {
    addToCart: tool({
      description: "Add the current product to the shopping cart",
      inputSchema: z.object({ quantity: z.number().int().min(1) }),
      // No execute — this is handled client-side
    }),
  }
})

When the AI calls addToCart, the return value from the client handler is sent back to the model as the tool result, allowing the conversation to continue.

Built-In Page Tools

ToolRegistered byDescription
fillBlogFormBlog new/edit pagesFills title, content, excerpt, and tags in the post editor
updatePageLayersUI builder edit pageReplaces the component layer tree in the page builder

API Reference

PageAIContextProvider

import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context"

<PageAIContextProvider>
  {children}
</PageAIContextProvider>

useRegisterPageAIContext(config)

import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"

useRegisterPageAIContext({
  routeName: string,           // shown as badge in chat header
  pageDescription: string,     // injected into system prompt (max 8,000 chars)
  suggestions?: string[],      // quick-action chips in chat empty state
  clientTools?: {              // handlers the AI can invoke
    [toolName: string]: (args: any) => Promise<{ success: boolean; message?: string }>
  }
})

AiChatBackendConfig — new options

OptionTypeDefaultDescription
enablePageToolsbooleanfalseActivate page tool support
clientToolSchemasRecord<string, Tool>Custom tool schemas for non-BTST pages
hooks.onBeforeActivateTools(toolNames, routeName, context) => string[]Apply a final domain/safety filter after typed tool authorization

Tool Authorization Hook

onBeforeActivateTools runs server-side after the structural routeName allowlist and the exact aiChat.tool.activate rule have succeeded. Use the typed rule for identity, role, subscription, or ownership policy. Use this hook only for exceptional server-side domain/safety narrowing.

lib/stack.ts
import type { AiChatBackendHooks } from "@btst/stack/plugins/ai-chat/api"

aiChatBackendPlugin({
  enablePageTools: true,
  hooks: {
    onBeforeActivateTools: async (toolNames, routeName, context) => {
      const disabled = await loadEmergencyToolDisableList()
      return toolNames.filter((name) => !disabled.has(name))
    },
  },
})
ParameterTypeDescription
toolNamesstring[]Tools that passed the routeName allowlist check
routeNamestring | undefinedClaimed route name from the request
contextChatApiContextFull request context (headers, body, etc.)

Return a subset of toolNames to allow, or [] to suppress all page tools. Throwing aborts the stream through the ordinary post-authorization operation error path.

The server authorizes the complete configured tool-name set before this hook or provider execution. Filtering cannot be used to smuggle an unapproved tool into the model, and client-supplied routeName/tool names never override the server allowlist.

Shadcn Registry

The AI Chat plugin UI layer is distributed as a shadcn registry block. Use the registry to eject and fully customize the page components while keeping all data-fetching and API logic from @btst/stack.

The registry installs only the view layer. Hooks and data-fetching continue to come from @btst/stack/plugins/ai-chat/client/hooks.

npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-ai-chat.json
pnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-ai-chat.json
bunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-ai-chat.json

This copies the page components into src/components/btst/ai-chat/client/ in your project. All relative imports remain valid and you can edit the files freely — the plugin's data layer stays intact.

Using ejected components

After installing, wire your custom components into the plugin via the pageComponents option in your client plugin config:

lib/stack-client.tsx
import { aiChatClientPlugin } from "@btst/stack/plugins/ai-chat/client"
// Import your ejected (and customized) page components
import { ChatPageComponent } from "@/components/btst/ai-chat/client/components/pages/chat-page"

aiChatClientPlugin({
  pageComponents: {
    chat: ChatPageComponent, // replaces the chat home page
    // Param routes receive the route context ({ params }) as props
    chatConversation: ({ params }) => (
      <ChatPageComponent conversationId={params.id} />
    ),
  },
})

Any key you omit falls back to the built-in default, so you can override just the pages you want to change.