Add chat to a React app
Introduction
Section titled “Introduction”You will add the kit’s chat to a React app: a conversation list, the thread and the composer, signed in with your app’s token. The same code can run one panel per agent, with your own labels, tool names and links.
What you need
Section titled “What you need”- React 18 or newer, Vite and Tailwind CSS v4 in your app.
@pilotoo/uiinstalled, and a server reachable from the browser (see Connecting a client).
1. Load the stylesheet and tell Tailwind where the components are
Section titled “1. Load the stylesheet and tell Tailwind where the components are”Tailwind never scans node_modules, so without @source the components render with the right markup and no styles. Load the stylesheet as an import, not as a <link>.
@import "tailwindcss";@import "@pilotoo/ui/tokens.css";@import "@pilotoo/ui/fonts.css";
@source "../node_modules/@pilotoo/ui/dist";import './styles.css';fonts.css is optional: leave it out to use the system fonts.
2. Mount the provider and the panel
Section titled “2. Mount the provider and the panel”ChatProvider owns the connection. ChatPanel is the thread and the composer for the open conversation. getToken returns the bare token; the kit adds Bearer.
import { ChatPanel, ChatProvider } from '@pilotoo/ui/chat';
import { getAccessToken } from '../auth/session';
export function AssistantPage({ agentId }: { agentId: string }) { return ( <ChatProvider key={agentId} baseUrl={import.meta.env.VITE_ASSISTANT_URL} getToken={() => getAccessToken() ?? undefined} agentId={agentId} storagePrefix={`assistant.${agentId}.`} > <ChatPanel className="h-full" /> </ChatProvider> );}Give the panel a height: it fills its parent. key={agentId} remounts the provider when the agent changes, which also opens a new socket.
3. Say the words in your language
Section titled “3. Say the words in your language”Every string is a label you can replace. toolLabels renames the tool rows the agent shows while it works.
import type { ChatLabels, ToolLabels } from '@pilotoo/ui/chat';
export const labels: Partial<ChatLabels> = { newConversation: 'New chat', composerPlaceholder: 'Ask about your invoices', send: 'Send',};
export const toolLabels: ToolLabels = { list_invoices: { running: 'Looking up invoices', done: 'Invoices found', describe: (args) => (typeof args.status === 'string' ? `status: ${args.status}` : undefined), },};<ChatPanel labels={labels} toolLabels={toolLabels} />4. Turn ids into links
Section titled “4. Turn ids into links”linkify rewrites the assistant’s markdown before it is drawn; renderLink draws each link. Use them to send INV-1042 to a route in your app instead of a new tab.
import { Link } from 'react-router-dom';import type { RenderLink } from '@pilotoo/ui/chat';
export const linkify = (markdown: string) => markdown.replace(/\bINV-(\d+)\b/g, (id) => `[${id}](/invoices/${id.slice(4)})`);
export const renderLink: RenderLink = ({ href, className, children }) => { // Only same-app paths. Anything else, including //host, stays a normal link. if (href.startsWith('/') && !href.startsWith('//')) { return <Link className={className} to={href}>{children}</Link>; } return undefined;};Returning undefined falls back to a plain anchor that opens in a new tab.
5. Replace the empty state
Section titled “5. Replace the empty state”empty receives send, prefill, the composer and an error. Place the composer wherever the screen wants it.
import type { ChatEmptyProps } from '@pilotoo/ui/chat';
export function EmptyState({ send, composer }: ChatEmptyProps) { return ( <div className="mx-auto flex max-w-xl flex-col gap-4 pt-24"> <h1 className="text-2xl font-semibold">How can I help?</h1> <button type="button" className="rounded-lg border px-3 py-2 text-left" onClick={() => void send('Which invoices are overdue?')}> Which invoices are overdue? </button> {composer} </div> );}<ChatPanel empty={(props) => <EmptyState {...props} />} />6. Configure Vite
Section titled “6. Configure Vite”In dev, linked workspace packages publish CommonJS, and Vite leaves a linked package alone. The symptom is does not provide an export named .... Pre-bundle the three entries. An installed copy of the packages does not need this.
import tailwindcss from '@tailwindcss/vite';import react from '@vitejs/plugin-react';import { defineConfig } from 'vite';
export default defineConfig({ plugins: [tailwindcss(), react()], optimizeDeps: { include: ['@pilotoo/chat-core', '@pilotoo/chat-core/socket-io', '@pilotoo/chat-protocol'], }, resolve: { dedupe: ['react', 'react-dom'] },});If you vendor the packages as tarballs, start Vite with --force after you replace one, or the old pre-bundle is used.
How it works
Section titled “How it works”ChatProviderbuilds one socket transport and the REST catalogs for conversations, agents and attachments. Every one of them callsgetTokenper request.- With
agentIdset, the list shows only that agent’s conversations, and new ones are bound to it.unscopedAgentIdalso counts conversations that have no agent as belonging to it. storagePrefixkeeps the “last open conversation” of two panels apart. The storage islocalStorageunless you passstorage.ChatPanelis an assembled example. For another layout, composeChatThread,ChatComposerandConversationListyourself from the same package.
Next steps
Section titled “Next steps”- Chat UI: every piece of
@pilotoo/ui/chat. - Tools that run in the browser: add
clientToolsto this provider. - Match your brand: recolour the panel.