React components and runtime for building Timbal chat UIs. Drop in a single component to get a fully-featured streaming chat interface connected to a Timbal workforce agent.
npm install @timbal-ai/timbal-react
# or
bun add @timbal-ai/timbal-reactPeer dependencies:
npm install react react-dom @assistant-ui/react @timbal-ai/timbal-sdkThe package ships pre-built Tailwind class names. Add this @source line to your CSS entry file — without it the components will be unstyled:
/* src/index.css */
@import "tailwindcss";
@source "../node_modules/@timbal-ai/timbal-react/dist";Adjust the path if your CSS file lives at a different depth relative to
node_modules.
Import these stylesheets once in your app entry:
// src/main.tsx
import "@assistant-ui/react-markdown/styles/dot.css";
import "katex/dist/katex.min.css";TimbalChat is a single component that handles everything — runtime, streaming, messages, and the composer:
import { TimbalChat } from "@timbal-ai/timbal-react";
export default function App() {
return (
<div style={{ height: "100vh" }}>
<TimbalChat workforceId="your-workforce-id" />
</div>
);
}
TimbalChatrequires a fixed height parent. Useheight: "100vh"orflex-1 min-h-0depending on your layout.
<TimbalChat
workforceId="your-workforce-id"
welcome={{
heading: "Hi, I'm your assistant",
subheading: "Ask me anything about your data.",
}}
suggestions={[
{ title: "Summarize this week", description: "Get a quick overview of recent activity" },
{ title: "What can you help with?" },
{ title: "Show me the latest report" },
]}
/><TimbalChat
workforceId="your-workforce-id"
composerPlaceholder="Type a question..."
maxWidth="60rem"
className="my-custom-class"
/>Pass key to fully reset the chat when the workforce changes:
const [workforceId, setWorkforceId] = useState("agent-a");
<select onChange={(e) => setWorkforceId(e.target.value)}>
<option value="agent-a">Agent A</option>
<option value="agent-b">Agent B</option>
</select>
<TimbalChat workforceId={workforceId} key={workforceId} />TimbalChat is a convenience wrapper around TimbalRuntimeProvider + Thread. Use them separately when you need to place the runtime above the chat — for example, to build a custom header that reads or controls chat state:
import { TimbalRuntimeProvider, Thread } from "@timbal-ai/timbal-react";
export default function App() {
return (
<TimbalRuntimeProvider workforceId="your-workforce-id">
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<header>My App</header>
<Thread
composerPlaceholder="Ask anything..."
className="flex-1 min-h-0"
/>
</div>
</TimbalRuntimeProvider>
);
}Useful when your API is mounted at a subpath (e.g. behind a reverse proxy):
<TimbalRuntimeProvider workforceId="your-workforce-id" baseUrl="/api">
<Thread />
</TimbalRuntimeProvider>Pass your own fetch to add headers, inject tokens, or proxy requests:
const myFetch: typeof fetch = (url, options) => {
return fetch(url, {
...options,
headers: { ...options?.headers, "X-My-Header": "value" },
});
};
<TimbalRuntimeProvider workforceId="your-workforce-id" fetch={myFetch}>
<Thread />
</TimbalRuntimeProvider>Use the components prop on TimbalChat or Thread to replace any part of the interface while keeping everything else as the default.
| Slot | Props forwarded | Default |
|---|---|---|
UserMessage |
none | built-in user bubble |
AssistantMessage |
none | built-in assistant bubble |
EditComposer |
none | built-in inline edit composer |
Composer |
placeholder |
built-in composer bar |
Welcome |
config, suggestions |
built-in welcome screen |
ScrollToBottom |
none | built-in scroll button |
Custom slot components read their data via hooks — no props are passed automatically except where noted above.
import { TimbalChat, MessagePrimitive } from "@timbal-ai/timbal-react";
const CompactUserMessage = () => (
<MessagePrimitive.Root className="flex justify-end px-4 py-2">
<div className="bg-primary text-primary-foreground rounded-2xl px-4 py-2 text-sm max-w-[75%]">
<MessagePrimitive.Parts />
</div>
</MessagePrimitive.Root>
);
<TimbalChat workforceId="..." components={{ UserMessage: CompactUserMessage }} />The Composer slot receives placeholder from the composerPlaceholder prop:
import { TimbalChat, ComposerPrimitive } from "@timbal-ai/timbal-react";
const MinimalComposer = ({ placeholder }: { placeholder?: string }) => (
<ComposerPrimitive.Root className="flex items-center gap-2 border rounded-full px-4 py-2">
<ComposerPrimitive.Input
placeholder={placeholder ?? "Type here..."}
className="flex-1 bg-transparent text-sm outline-none"
rows={1}
/>
<ComposerPrimitive.Send className="text-primary font-medium text-sm">
Send
</ComposerPrimitive.Send>
</ComposerPrimitive.Root>
);
<TimbalChat workforceId="..." components={{ Composer: MinimalComposer }} />The Welcome slot is always mounted and controls its own visibility. Use useThread to replicate the default "show only when the thread is empty" behaviour:
import { TimbalChat, useThread, useThreadRuntime, type ThreadWelcomeProps } from "@timbal-ai/timbal-react";
const BrandedWelcome = ({ suggestions }: ThreadWelcomeProps) => {
const isEmpty = useThread((s) => s.isEmpty);
const runtime = useThreadRuntime();
if (!isEmpty) return null;
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<img src="/logo.svg" className="h-12" />
<h2 className="text-xl font-semibold">Welcome to Acme AI</h2>
<div className="flex gap-2 flex-wrap justify-center">
{suggestions?.map((s) => (
<button
key={s.title}
onClick={() => runtime.append({ role: "user", content: [{ type: "text", text: s.title }] })}
className="border rounded-full px-4 py-1.5 text-sm hover:bg-muted"
>
{s.title}
</button>
))}
</div>
</div>
);
};
<TimbalChat
workforceId="..."
suggestions={[{ title: "Get started" }, { title: "Show me an example" }]}
components={{ Welcome: BrandedWelcome }}
/>Override any combination — slots are independent of each other:
<TimbalChat
workforceId="..."
components={{
UserMessage: CompactUserMessage,
Composer: MinimalComposer,
}}
/>These are re-exported from @assistant-ui/react for use inside custom slot components:
| Export | Use inside |
|---|---|
ThreadPrimitive |
Any slot |
MessagePrimitive |
UserMessage, AssistantMessage, EditComposer |
ComposerPrimitive |
Composer, EditComposer |
ActionBarPrimitive |
UserMessage, AssistantMessage |
useThread |
Any slot — subscribe to thread state (e.g. isRunning, isEmpty) |
useThreadRuntime |
Any slot — call actions (e.g. runtime.append(...)) |
useMessageRuntime |
UserMessage, AssistantMessage — edit, reload, branch |
useComposerRuntime |
Composer, EditComposer — access composer state |
TimbalChat accepts all TimbalRuntimeProvider props plus all Thread props.
| Prop | Type | Default | Description |
|---|---|---|---|
workforceId |
string |
required | ID of the workforce to stream from |
baseUrl |
string |
"/api" |
Base URL for API calls. Posts to {baseUrl}/workforce/{workforceId}/stream |
fetch |
(url, options?) => Promise<Response> |
authFetch |
Custom fetch. Defaults to the built-in auth-aware fetch (Bearer token + auto-refresh) |
welcome.heading |
string |
"How can I help you today?" |
Welcome screen heading |
welcome.subheading |
string |
"Send a message to start a conversation." |
Welcome screen subheading |
suggestions |
{ title: string; description?: string }[] |
— | Suggestion chips on the welcome screen |
composerPlaceholder |
string |
"Send a message..." |
Composer input placeholder |
components |
ThreadComponents |
— | Override individual UI slots |
maxWidth |
string |
"44rem" |
Max width of the message column |
className |
string |
— | Extra classes on the root element |
Same as TimbalChat minus workforceId, baseUrl, and fetch (those live on TimbalRuntimeProvider).
| Prop | Type | Default | Description |
|---|---|---|---|
workforceId |
string |
required | ID of the workforce to stream from |
baseUrl |
string |
"/api" |
Base URL for API calls |
fetch |
(url, options?) => Promise<Response> |
authFetch |
Custom fetch function |
The package includes an optional session/auth system backed by localStorage tokens. The API is expected to expose /api/auth/login, /api/auth/logout, and /api/auth/refresh.
Auth is opt-in — it only activates when VITE_TIMBAL_PROJECT_ID is set in your environment.
Wrap your app with SessionProvider and protect routes with AuthGuard:
// src/App.tsx
import { SessionProvider, AuthGuard, TooltipProvider } from "@timbal-ai/timbal-react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
const isAuthEnabled = !!import.meta.env.VITE_TIMBAL_PROJECT_ID;
export default function App() {
return (
<SessionProvider enabled={isAuthEnabled}>
<TooltipProvider>
<BrowserRouter>
<AuthGuard requireAuth enabled={isAuthEnabled}>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</AuthGuard>
</BrowserRouter>
</TooltipProvider>
</SessionProvider>
);
}When enabled is false, both SessionProvider and AuthGuard are transparent — no redirects, no API calls.
Access the current session anywhere inside SessionProvider:
import { useSession } from "@timbal-ai/timbal-react";
function Header() {
const { user, isAuthenticated, loading, logout } = useSession();
if (loading) return null;
return (
<header>
{isAuthenticated ? (
<>
<span>{user?.email}</span>
<button onClick={logout}>Log out</button>
</>
) : (
<a href="/login">Log in</a>
)}
</header>
);
}A drop-in replacement for fetch that attaches the Bearer token from localStorage and auto-refreshes on 401. It's also the default fetch used by TimbalRuntimeProvider, so you only need to import it directly for your own API calls (e.g. loading workforce lists):
import { authFetch } from "@timbal-ai/timbal-react";
const res = await authFetch("/api/workforce");
if (res.ok) {
const agents = await res.json();
}| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
SessionProvider |
enabled |
boolean |
true |
When false, session is always null and no API calls are made |
AuthGuard |
requireAuth |
boolean |
false |
Redirect to login if not authenticated |
AuthGuard |
enabled |
boolean |
true |
When false, renders children unconditionally |
| Export | Description |
|---|---|
Thread |
Full chat UI — messages, composer, attachments, action bar |
MarkdownText |
Markdown renderer with GFM, math (KaTeX), and syntax highlighting |
ToolFallback |
Animated "Using tool: …" indicator shown while a tool runs |
SyntaxHighlighter |
Shiki-based code highlighter (vitesse-dark / vitesse-light themes) |
UserMessageAttachments |
Attachment thumbnails in user messages |
ComposerAttachments |
Attachment previews inside the composer |
ComposerAddAttachment |
"+" button to add attachments |
TooltipIconButton |
Icon button with a tooltip |
Re-exported Radix UI wrappers pre-styled to match the Timbal design system:
Button · Tooltip · TooltipTrigger · TooltipContent · TooltipProvider · Avatar · AvatarImage · AvatarFallback · Dialog · DialogContent · DialogTitle · DialogTrigger · Shimmer
A complete page with agent switching, auth, and a custom header:
// src/pages/Home.tsx
import { useEffect, useState } from "react";
import type { WorkforceItem } from "@timbal-ai/timbal-sdk";
import { TimbalChat, Button, authFetch, useSession } from "@timbal-ai/timbal-react";
import { LogOut } from "lucide-react";
const isAuthEnabled = !!import.meta.env.VITE_TIMBAL_PROJECT_ID;
export default function Home() {
const { logout } = useSession();
const [workforces, setWorkforces] = useState<WorkforceItem[]>([]);
const [selectedId, setSelectedId] = useState("");
useEffect(() => {
authFetch("/api/workforce")
.then((r) => r.json())
.then((data: WorkforceItem[]) => {
setWorkforces(data);
const agent = data.find((w) => w.type === "agent") ?? data[0];
if (agent) setSelectedId(agent.id ?? agent.name ?? "");
})
.catch(() => {});
}, []);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<header style={{ display: "flex", justifyContent: "space-between", padding: "0.5rem 1.25rem" }}>
<select value={selectedId} onChange={(e) => setSelectedId(e.target.value)}>
{workforces.map((w) => (
<option key={w.id ?? w.name} value={w.id ?? w.name ?? ""}>
{w.name}
</option>
))}
</select>
{isAuthEnabled && (
<Button variant="ghost" size="icon" onClick={logout}>
<LogOut />
</Button>
)}
</header>
<TimbalChat
workforceId={selectedId}
key={selectedId}
className="flex-1 min-h-0"
welcome={{ heading: "How can I help you today?" }}
/>
</div>
);
}Install via a local path reference:
{
"dependencies": {
"@timbal-ai/timbal-react": "file:../../timbal-react"
}
}Adjust the relative path to where timbal-react lives on your machine.
After editing source files, rebuild:
cd timbal-react
bun run build # one-off build
bun run build:watch # rebuild on every changeVite picks up the new dist/ automatically via HMR — no reinstall needed.