TrademarkTrademark
Guides
Surfaces

AI chat

An agent chat that runs on a scripted mock, with two interfaces between it and a real model and database.
The chat surface is the interface of an AI assistant: a sidebar of chats grouped into pinned, projects and recents, a conversation where agent steps and widgets stream in, and a resizable side panel with the assistant's mascot, the latest run and the widgets the chat has made. A chat's model, instructions and theme are in its settings dialog. Below the desktop breakpoint the panels become drawers. It serves /chat and requires no slices. Chats live in localStorage and answers come from a mock model in the browser, so it runs on zero environment variables and no API key. Without the landing surface, / redirects to /chat.

Two seams

Everything the UI needs from outside goes through two interfaces in src/lib/chat, and the hook that drives the UI, useChats, reads only them. Where chats live is ChatStore, in store.ts: It ships as localChatStore, seeded with sample chats. Keeping chats in a database, per user or per organization, is a new object with this shape assigned to chatStore in the same file. Where answers come from is ChatModel, in model.ts: stream yields ChatEvents: text deltas, steps (a step sent again with the same id updates it), widgets, and a final usage with the tokens the answer used. The surface ships mockModel, which plays canned agent runs picked by what the message is about, and quickModel, the same without steps.

Claude

The surface ships one real model, Claude, through the route in src/app/(chat)/api/chat/route.ts. Set ANTHROPIC_API_KEY and it appears in the model picker; without the key the picker keeps the mocks and nothing calls out. CHAT_CLAUDE_MODEL picks the model (claude-opus-5-5 by default, at low effort, which suits chat). The key stays on the server: the browser posts the conversation and reads back ChatEvents, one JSON object a line. Every answer is billed to the deployment's key, so the route is not an open relay. src/lib/chat/access.ts lets a request through only when:
  • it comes from the site's own pages (its Origin is this host);
  • someone is signed in, when Supabase is configured (demo mode has no accounts and skips this);
  • the Upstash rate limit allows it: 10 answers a minute and 200 a day per user, or per IP in demo mode. In production the route answers 503 and the picker hides Claude until UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are set; in development it runs without them.
What one request may carry is capped as well: the latest 20 turns, 4,000 characters a turn, 1,000 characters of chat instructions, and 2,000 output tokens an answer. The limits are constants at the top of the two files. Another provider is the same two parts: a route that turns its stream into ChatEvents, and a ChatModel in model.ts that reads it.

Components in replies

A model never writes markup into a message. It calls a tool from the component catalog in src/lib/chat/catalog.ts with data: a checklist, a line chart, a table, sources, or reply choices the person can send with one tap. The tools are strict, so their arguments match the schema, and widgetFromTool checks what a schema cannot say (a cell per column, only web links) before the chat renders the widget with Once UI. A call that does not fit is dropped and the model is told, so a bad call costs a widget, never the page. A new kind is a tool in catalog.ts, a member of Widget in types.ts and a case in src/app/(chat)/components/Widgets.tsx. Reply text is Markdown, rendered with core's MDX elements: lists, links, tables and code blocks with copy look like the rest of the product. Raw HTML is not rendered. The last answer can be regenerated and the last message edited and sent again. The daily allowance in the usage meter is DAILY_TOKEN_LIMIT, a constant in model.ts, and each model's rate says how much of it one token costs.

Themes

Every chat and project carries a ChatTheme: a brand, an accent and a neutral from Once UI's own palette. ChatThemeScope applies it to the chat's subtree as data-brand, data-accent and data-neutral, so every component inside takes the chat's colors without a prop. A chat nobody has themed by hand follows its project, including when it is moved into one; outside a project, autoTheme picks a stable theme from the chat's id. Picking colors in the theme dialog makes them the chat's own.

Mascot and companion mode

The agent has a face: core's Mascot, at the top of the side panel, in the chat's brand color, so each chat's theme is also a persona. It listens while the person types or talks, thinks while a reply is on its way, speaks while the reply comes in or is read aloud, and then keeps a mood that fits how the reply went: determined after a plan, focused after a chart or sources, sad after an error. Both rules are plain functions in src/lib/chat/mascot.ts, mascotState and moodFor, so another mascot or another surface can reuse them. Companion mode (the sparkle button in the header, or mod+shift+k) makes the mascot the conversation: it fills the stage, follows the pointer, and the latest reply is its caption. The transcript is one click away.

Voice

The mic in the composer dictates with the browser's speech recognition: the words appear in the field as they are said, and the message sends itself when the person stops talking. A spoken message gets a spoken reply; the speaker button in the header reads every reply aloud. Both use the Web Speech API in src/lib/chat/voice.ts, which costs nothing and needs no key. Where the browser lacks recognition (Firefox), the mic stays hidden. A paid voice, such as a realtime model or a text-to-speech provider, replaces useSpeechInput and useSpeechOutput and keeps their shape. The conversation itself is drawn with core's chat module: ChatMessage, ChatMessageList and TypingIndicator.
Last updated October 23, 2026
Edit this page on GitHub