@timbal-ai/timbal-react
v4.2.2
Published
React components and runtime for building Timbal chat and studio apps
Readme
@timbal-ai/timbal-react
React components and runtime for building Timbal chat UIs and studio apps. Drop in a single component to get a fully-featured streaming chat interface connected to a Timbal workforce agent, or compose dashboards with the app kit.
Package structure (2.0)
| Subpath | Use when |
|---------|----------|
| @timbal-ai/timbal-react | Full surface (chat shells, auth, artifacts, app kit) |
| @timbal-ai/timbal-react/chat | Chat-only apps — Thread, Composer, runtime, layout helpers |
| @timbal-ai/timbal-react/studio | Studio chrome — TimbalChatShell, TimbalStudioShell, sidebar |
| @timbal-ai/timbal-react/ui | Primitives — Button, Dialog, DropdownMenu, Popover, Select, Tooltip, Avatar, Shimmer |
| @timbal-ai/timbal-react/app | Dashboards — AppShell, Page, DataTable, StatTile, … |
| @timbal-ai/timbal-react/site | Marketing/brand motion — Reveal, TextReveal, Parallax, Marquee, Magnetic |
| @timbal-ai/timbal-react/styles.css | Theme tokens (required once) |
API tiers
- Stable —
TimbalChat, shells,Thread,Composer, auth,styles.css, documented app kit components. - Composable —
@assistant-ui/reactprimitives re-exported from the main entry; message column classes from./chat(threadMessageColumnClass,assistantMessageRootClass, …). - Internal —
src/design/*class composites (not exported); extend via CSS variables or public layout helpers.
import { threadMessageColumnClass } from "@timbal-ai/timbal-react/chat";
import { AppShell, Page, StatTile } from "@timbal-ai/timbal-react/app";Installation
npm install @timbal-ai/timbal-react
# or
bun add @timbal-ai/timbal-reactPeer dependencies:
npm install react react-dom react-is @assistant-ui/react @timbal-ai/timbal-sdkThe app-kit charts are built on recharts (installed automatically).
react-isis a recharts peer and must match your React version — install it explicitly if your package manager doesn't hoist peers.Required for React 19: pin
immerto ≥ 11.0.1 in your app. recharts stores React elements in a Redux-Toolkit/immer store, and immer 11.0.0 freezes React 19's Fiber internals — charts crash withCannot assign to read only property 'lanes'(a blank route). Add an override so a stale lockfile can't reintroduce it:{ "overrides": { "immer": ">=11.0.1" } }Yarn uses
"resolutions". Fresh installs already resolve a safe immer; the override just makes it durable.
Tailwind setup
The package ships pre-built Tailwind class names plus a complete light + dark token set (bg-background, text-foreground, bg-card, bg-bubble-user, from-elevated-from, bg-playground-from/via/to, etc.). Your app CSS only needs three lines:
/* src/index.css */
@import "tailwindcss";
@import "@timbal-ai/timbal-react/styles.css";
@source "../node_modules/@timbal-ai/timbal-react/dist";That's it — no @theme, :root, or .dark blocks of your own. Toggling dark mode is a single document.documentElement.classList.toggle("dark") (or next-themes attribute="class"). Wire next-themes with defaultTheme="light" and enableSystem={false} so new apps ship in light mode; dark appears only when the user toggles. The built-in ModeToggle (uncontrolled) persists to localStorage key timbal-theme and restores on reload.
Adjust the
@sourcepath if your CSS file lives at a different depth relative tonode_modules.
Overriding the palette
Every token has a CSS-variable indirection in styles.css. Override individual variables to rebrand without forking:
:root {
--primary: oklch(0.5 0.12 265);
--playground-from: oklch(0.95 0.04 265 / 0.6);
/* Chart series — override any to rebrand every dashboard chart at once */
--chart-1: var(--primary);
--chart-2: oklch(0.62 0.13 184);
}
.dark {
--primary: oklch(0.72 0.14 265);
--playground-from: oklch(0.27 0.04 265);
--chart-1: var(--primary);
}Both light AND dark blocks must define every overridden token — otherwise toggling dark mode produces an inconsistent UI. The library prints a one-time dev-only console warning when it detects a mismatch.
Charts (app kit + artifacts)
Dashboard and in-chat charts use the native shadcn/recharts layer — animated tooltips, hover crosshairs, and legends that match shadcn/ui out of the box.
| Import from | Components |
|-------------|------------|
| @timbal-ai/timbal-react or /app | LineAreaChart, PieChart, RadialChart, RadarChart, Sparkline, ChartPanel, MetricChartCard |
| @timbal-ai/timbal-react/ui | ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartConfig |
Dependencies: recharts ships with the package. Install react-is alongside React (same major version) — it is a recharts peer and must match your React version.
Flush dashboards (ChartPanel, MetricChartCard, cartesian artifacts): charts default to layout="flush" — no axis tick labels; hover tooltips show the category (xKey) and formatted value(s). Opt back in with showXAxis / showYAxis on LineAreaChart, or showAxes: true on a ChartArtifact. Use layout="default" when you want visible axes without passing extra props.
Rebrand: override --chart-1 … --chart-6 in :root / .dark (series 1 defaults to --primary). Per-series overrides: series[].color on LineAreaChart or colors on pie/radial artifacts.
ChartArtifact kinds: bar, horizontalBar, line, area, pie, donut, radial, radar — see ChartPanel + the app-kit Chart catalog recipe (examples/app-kit/src/recipes/chart-catalog.tsx).
Custom charts (same pattern as shadcn docs):
import { Bar, BarChart, XAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@timbal-ai/timbal-react/ui";
const config = { revenue: { label: "Revenue", color: "var(--chart-1)" } } satisfies ChartConfig;
<ChartContainer config={config} className="h-[240px] w-full">
<BarChart data={rows} accessibilityLayer>
<XAxis dataKey="month" hide />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="revenue" fill="var(--color-revenue)" radius={4} />
</BarChart>
</ChartContainer>Programmatic theming (no hand-authored OKLCH)
Instead of hand-writing paired :root / .dark blocks, derive a complete personality — color, roundness, shadows, and fonts — from a single intent object. The package owns the OKLCH math for every color token (primary, foreground, ring, the full button gradient, the playground tint):
import { createTimbalTheme, themeToCss, applyTimbalTheme } from "@timbal-ai/timbal-react";
const theme = createTimbalTheme({
brand: "#4f46e5",
radius: 0.875, // corner roundness (rem) → --radius + --radius-2xl
shadow: "soft", // "none" | "hairline" | "soft" | "medium" | "strong"
typography: { // optional — re-skins every component's font
sans: '"Geist", ui-sans-serif, system-ui, sans-serif',
importUrl: "https://fonts.googleapis.com/css2?family=Geist:[email protected]&display=swap",
},
});
// Build-time / SSR — paste into index.css (paired light + dark, always in sync):
const css = themeToCss(theme);
// Runtime — inject a managed <style> (+ font <link>), swappable, returns a disposer:
const dispose = applyTimbalTheme(theme);Fonts must be loaded.
applyTimbalThemeandTimbalThemeStyleinject the<link>fortypography.importUrlautomatically. For build-timethemeToCss, add the<link rel="stylesheet">toindex.htmlyourself (or passthemeToCss(theme, { includeFontImport: true })when the result is a standalone stylesheet).
Or render it as a component near your app root:
import { TimbalThemeStyle } from "@timbal-ai/timbal-react";
<TimbalThemeStyle theme={createTimbalTheme({ brand: "#4f46e5" })} />
// or: <TimbalThemeStyle preset="indigo" />Presets
A small closed catalog (TIMBAL_THEME_PRESETS) lets you apply a brand by stable id. Each preset is a full personality (color + radius + shadows + font), not just a color: platform (system), indigo (Geist), violet (Sora), forest/warm (Lexend), slate (Inter), folio (Fraunces serif), carbon (JetBrains Mono). Choose a preset at build/config time and apply it programmatically:
import { applyThemePreset } from "@timbal-ai/timbal-react";
applyThemePreset("indigo");applyThemePreset persists the choice to localStorage (timbal-theme-preset); getStoredThemePreset() restores it on reload.
The visual theme picker (
ThemePresetGallery) is an internal/dev tool and is intentionally not part of the public API — theming is a developer/config choice, not an end-user selector in shipped apps.
For UI-generation agents: inject THEME_AGENT_INSTRUCTIONS into the system prompt so the model themes via these APIs (and never emits raw OKLCH), mirroring APP_KIT_AGENT_INSTRUCTIONS.
Anti-slop guardrails for generated UIs
When an agent composes app-kit UIs, taste is enforced — not just suggested — through three layers built on one shared vocabulary (HOUSE_RULES, SEMANTIC_COLOR_TOKENS, SLOP_BUDGETS):
import {
lintGeneratedUi,
reviewGeneratedUi,
UI_REVIEW_AGENT_INSTRUCTIONS,
} from "@timbal-ai/timbal-react"; // or "/app"
// 1. Prompt: APP_KIT_AGENT_INSTRUCTIONS renders its anti-slop checklist from
// HOUSE_RULES; add UI_REVIEW_AGENT_INSTRUCTIONS so the model self-reviews.
const systemPrompt = `${basePrompt}\n\n${APP_KIT_AGENT_INSTRUCTIONS}\n\n${UI_REVIEW_AGENT_INSTRUCTIONS}`;
// 2. Lint: deterministic checks reject hardcoded palette colors / hex / oklch
// (errors) and flag icon-spam, bold giant values, per-row dividers, and
// gradients on data surfaces (warnings).
const { ok, findings } = lintGeneratedUi(generatedTsx);
// 3. Critique loop: review → fix → re-review until it passes clean.
const review = reviewGeneratedUi(generatedTsx, { strict: true });
if (!review.passed) regenerate(review.revisionPrompt); // names the exact lines + fixesColor decisions belong to the theme generator and semantic tokens, never to the per-component agent — the linter enforces exactly that.
CSS imports
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";Quick start
Basic usage
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.
Welcome screen and suggestions
<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" },
]}
/>Suggestions also accept a function (sync or async) for per-user or server-driven chips:
<TimbalChat
workforceId="your-workforce-id"
suggestions={async () => {
const res = await authFetch("/api/suggestions");
return res.json(); // ThreadSuggestion[]
}}
/>Each chip supports icon, description, and prompt (sent instead of title when clicked).
In the floating copilot panel (Thread variant="panel"), welcome suggestions are off by default. Enable them with showWelcomeSuggestions (forwarded by AppCopilot to the panel):
<AppCopilot
workforceId="your-workforce-id"
showWelcomeSuggestions
suggestions={[{ title: "Summarize this dashboard" }]}
/>Placeholder and width
<TimbalChat
workforceId="your-workforce-id"
composerPlaceholder="Type a question..."
maxWidth="60rem"
className="my-custom-class"
/>Scroll behavior
The composer textarea grows with CSS field-sizing, so typing a long message
never triggers an auto-scroll. The conversation auto-follows the stream by
default; tune that with these Thread / TimbalChat / CopilotPanel props (all
default true):
<TimbalChat
workforceId="your-workforce-id"
autoScroll // follow new content as it streams (default true)
scrollToBottomOnRunStart // jump to bottom when you send a message
scrollToBottomOnInitialize // jump to bottom after history loads
scrollToBottomOnThreadSwitch // jump to bottom when switching threads
/>Set any to false to opt out — e.g. autoScroll={false} keeps your scroll
position pinned while a response streams in (the scroll-to-bottom button still
works).
Switching agents dynamically
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} />Studio shell (sidebar + header + chat)
TimbalStudioShell is the most opinionated layout — a floating workforce sidebar, a top bar for actions (mode toggle, account), and a full-height TimbalChat. Works as a one-line app:
import {
TimbalStudioShell,
ModeToggle,
TimbalMark,
} from "@timbal-ai/timbal-react";
import { useTheme } from "next-themes";
export default function App() {
const { resolvedTheme, setTheme } = useTheme();
return (
<TimbalStudioShell
brand={<TimbalMark size={32} />}
welcome={{ heading: "How can I help you today?" }}
headerActions={
<ModeToggle theme={resolvedTheme} setTheme={setTheme} />
}
suggestions={[{ title: "Get started" }]}
attachments
/>
);
}When workforceId is omitted, the shell fetches the workforce list and lets the sidebar drive selection. Pass workforceId to pin a single agent and hide the picker UI.
Apps that need finer control can compose the public building blocks (StudioSidebar + TimbalChat) directly:
import { StudioSidebar, TimbalChat } from "@timbal-ai/timbal-react";
function MyShell() {
const [agent, setAgent] = useState("agent-a");
return (
<div className="relative h-dvh bg-background">
<StudioSidebar selectedId={agent} onSelect={setAgent} />
<main className="h-full pl-[var(--studio-inset-left)]">
<TimbalChat workforceId={agent} key={agent} />
</main>
</div>
);
}--studio-inset-left is a static CSS variable (the expanded sidebar width) — good for layouts where the sidebar never collapses. It does not track the collapse animation on its own.
To inset a main column that follows the sidebar as it collapses, use AppShell, which wires the tracking automatically:
import { AppShell, StudioSidebar } from "@timbal-ai/timbal-react";
function MyShell() {
const [agent, setAgent] = useState("agent-a");
return (
<AppShell
// StudioSidebar is rendered through AppShell's `sidebar` prop, so it's a
// descendant of the shell and auto-syncs to the mobile-nav drawer — no
// mobileOpen / useAppShellNav wiring. AppShell renders the mobile
// hamburger itself (when there's a sidebar and no topbar). The sidebar
// also closes itself on selection (mobile).
sidebar={<StudioSidebar selectedId={agent} onSelect={setAgent} />}
>
{/* main content insets + animates with the sidebar */}
</AppShell>
);
}Do not call
useAppShellNav()in the component that renders<AppShell>and feed itsopen/setOpenintoStudioSidebar'smobileOpen— that hook reads the shell context, so outside the shell it returns a no-op (openstuckfalse) and pins the drawer shut. LetStudioSidebarauto-sync instead (above).useAppShellNav()is only for a custom trigger rendered insideAppShell.
For a fully custom shell, drive your own offset from StudioSidebar's onInsetChange callback, which fires with the live inset width (px) whenever the collapse state changes.
The AppShell content region is a padded scroll area by default. For a full-bleed page that fills the viewport (a full-page chat, a canvas, an editor, a split master–detail view), pass contentFill to AppShell and fill to Page (omit Page.title for a headerless page), then give the filling child min-h-0 flex-1 — the composer / footer stays pinned and you avoid h-[calc(100dvh-…)] guesses:
<AppShell contentFill>
<Page fill>
<TimbalChat workforceId="…" className="min-h-0 flex-1" />
</Page>
</AppShell>Drop-in shell (header + agent picker)
TimbalChatShell wraps the common blueprint layout: brand area, workforce selector, optional header actions, and a full-height chat. When workforceId is omitted, it fetches {baseUrl}/workforce and selects the first agent automatically:
import { TimbalChatShell, Button, useSession } from "@timbal-ai/timbal-react";
export default function App() {
const { logout, isAuthenticated } = useSession();
return (
<TimbalChatShell
brand={<span className="font-semibold">Acme AI</span>}
headerActions={
isAuthenticated ? (
<Button variant="ghost" size="sm" onClick={logout}>
Log out
</Button>
) : null
}
welcome={{ heading: "How can I help you today?" }}
suggestions={[{ title: "Get started" }]}
/>
);
}Pass workforceId to lock the agent and hide the built-in selector. Use hideWorkforceSelector when you render your own picker.
Workforce list hook
For custom layouts (sidebar tree, command palette), use useWorkforces with the optional WorkforceSelector:
import {
TimbalChat,
useWorkforces,
WorkforceSelector,
} from "@timbal-ai/timbal-react";
function ChatWithPicker() {
const { workforces, selectedId, setSelectedId, isLoading } = useWorkforces();
if (isLoading) return <div>Loading agents…</div>;
return (
<div className="flex h-screen flex-col">
<WorkforceSelector
workforces={workforces}
value={selectedId}
onChange={setSelectedId}
/>
<TimbalChat workforceId={selectedId} key={selectedId} className="min-h-0 flex-1" />
</div>
);
}useWorkforces accepts baseUrl, fetch, and pickInitial (custom resolver for the default selection). It returns selected, error, and refresh() as well.
Conversation history (app runs)
Timbal stores one run per conversation turn. Runs that share a group_id form a thread; the thread root is the run with no parent (group_id === id). The package ships a data layer to list past conversations, reopen one in <Thread>, and continue it.
Host requirement. These hooks read
{baseUrl}/runsand{baseUrl}/runs/{id}(defaultbaseUrl/api). Your host proxy must map them to the platform runs surface (GET /orgs/{org}/projects/{project}/runs), injecting org/project + auth — the same proxy that already serves{baseUrl}/workforce. Override the segment with therunsPathoption if your proxy mounts it elsewhere.
List conversations
useConversations lists thread roots (roots=true) for a workforce, with offset pagination:
import { useConversations } from "@timbal-ai/timbal-react";
function ConversationList({ workforceId, onPick }) {
const { conversations, isLoading, hasMore, loadMore } = useConversations({
workforceId,
});
if (isLoading) return <div>Loading…</div>;
return (
<ul>
{conversations.map((c) => (
<li key={c.id}>
<button onClick={() => onPick(String(c.id))}>
{new Date(c.created_at ?? "").toLocaleString()}
</button>
</li>
))}
{hasMore && <button onClick={loadMore}>Load more</button>}
</ul>
);
}Root rows are ordered by thread creation time and don't carry aggregates (turn count, last message, cost) — fetch the trace (useConversation / getRun) when you need a title or preview.
Open a conversation and continue it
useConversation fetches every turn in a thread, hydrates each turn's trace, and reconstructs <Thread>-ready ChatMessage[] (text, thinking, tool calls with results, attachments). Load them into a live runtime with useTimbalRuntime().loadMessages(...); the last assistant runId becomes the parent so the next send continues the same thread:
import { useEffect } from "react";
import {
TimbalRuntimeProvider,
Thread,
useTimbalRuntime,
useConversation,
} from "@timbal-ai/timbal-react";
function ConversationLoader({ workforceId, conversationId }) {
const { messages, isLoading } = useConversation({ workforceId, conversationId });
const { loadMessages } = useTimbalRuntime();
useEffect(() => {
if (!isLoading) loadMessages(messages);
}, [isLoading, messages, loadMessages]);
return null;
}
function ChatPage({ workforceId, conversationId }) {
return (
<TimbalRuntimeProvider workforceId={workforceId}>
<ConversationLoader workforceId={workforceId} conversationId={conversationId} />
<Thread />
</TimbalRuntimeProvider>
);
}Lower-level building blocks
| Export | Purpose |
|--------|---------|
| listRuns(params) | Raw runs list (roots, groupId, workforceId, pageToken, …) → { runs, next_page_token } |
| getRun({ runId }) | One RunDetail including its span trace |
| orderRunsForThread(runs) | Sort runs into thread order (parent-first, else chronological) |
| runTraceToMessages(trace, { runId }) | One turn's trace → [user, assistant] messages |
| conversationRunsToMessages(runs, detailByRunId) | Whole thread → ordered ChatMessage[] |
| TimbalStreamApi.loadMessages(messages) | Replace runtime messages (hydrate a stored thread) |
Splitting the runtime and UI
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>
);
}Custom API base URL
Useful when your API is mounted at a subpath (e.g. behind a reverse proxy):
<TimbalRuntimeProvider workforceId="your-workforce-id" baseUrl="/api">
<Thread />
</TimbalRuntimeProvider>Attachments
Attachments are opt-in. Pass attachments to enable the composer + button, drag-and-drop, and multimodal prompts:
<TimbalChat workforceId="your-workforce-id" attachments />When enabled, each file is uploaded via POST to ${baseUrl}/files/upload (multipart file field). The response must include { url } (or { signed_url } / { id }). That URL is sent to the workforce as { type: "file", file: "<url>" } alongside { type: "text", text: "..." } when the user typed a message.
Your API must expose that upload route (the Timbal blueprint API includes it). authFetch is used by default and must not force a Content-Type header on FormData uploads.
Variants
// Default upload adapter
<TimbalChat workforceId="..." attachments />
// Custom endpoint or MIME whitelist
<TimbalChat
workforceId="..."
attachments={{ uploadUrl: "/api/uploads", accept: "image/*,application/pdf" }}
/>
// Fully custom adapter (e.g. presigned S3)
<TimbalChat workforceId="..." attachments={myAdapter} />
// Explicitly off (default when prop is omitted)
<TimbalChat workforceId="..." attachments={null} />Power-user exports
import {
createDefaultAttachmentAdapter,
createUploadAttachmentAdapter,
resolveAttachmentAdapter,
parseSSELine,
AssistantRuntimeProvider,
useTimbalStream,
} from "@timbal-ai/timbal-react";parseSSELine and AssistantRuntimeProvider are re-exported so custom runtimes do not need a second @assistant-ui/react import for those symbols. useTimbalStream exposes the same SSE reducer and send / reload / cancel API without mounting <Thread>.
Custom fetch function
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>Customizing the UI
Use the components prop on TimbalChat or Thread to replace any part of the interface while keeping everything else as the default.
Available slots
| 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 (+ full ComposerProps) | built-in composer bar |
| Welcome | config, suggestions, Suggestions | built-in welcome screen |
| Suggestions | suggestions | built-in suggestion chips |
| ScrollToBottom | none | built-in scroll button |
Custom slot components read their data via hooks — no props are passed automatically except where noted above.
Custom user message
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 }} />Custom composer
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 }} />Custom welcome screen
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 }}
/>Mixing slots
Override any combination — slots are independent of each other:
<TimbalChat
workforceId="..."
components={{
UserMessage: CompactUserMessage,
Composer: MinimalComposer,
}}
/>Hooks and primitives
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 |
Artifacts
Agents can return structured JSON artifacts — charts, tables, choice widgets, and interactive UI — instead of plain text. The chat UI renders them automatically from tool results or inline ```timbal-artifact fences.
Tell the agent about the schema
Import the ready-made instruction block and append it to your workforce system prompt (or blueprint tool-result docs):
import { ARTIFACT_AGENT_INSTRUCTIONS } from "@timbal-ai/timbal-react";
const systemPrompt = `${basePrompt}\n\n${ARTIFACT_AGENT_INSTRUCTIONS}`;ARTIFACT_AGENT_INSTRUCTIONS documents every built-in type (chart, table, question, html, json, ui) and the full interactive ui node palette (hover tooltips, buttons, toggles, sliders, drag).
Subscribe to interactive events
ui artifacts can fire { kind: "emit" } actions (e.g. after a slider commit or drag). Handle them with onArtifactEvent on Thread or TimbalChat:
<TimbalChat
workforceId="your-workforce-id"
onArtifactEvent={(event) => {
console.log(event.name, event.payload);
// e.g. refetch data, update local UI, call your API
}}
/>When using Thread directly:
<TimbalRuntimeProvider workforceId="your-workforce-id">
<Thread
onArtifactEvent={(event) => console.log(event.name, event.payload)}
/>
</TimbalRuntimeProvider>Built-in { kind: "message" } actions already append a user message — you only need onArtifactEvent for host-side logic beyond that.
Custom artifact renderers
Register extra type values or override defaults:
<TimbalChat
workforceId="..."
artifacts={{
renderers: {
"my:widget": MyWidgetRenderer,
},
}}
/>Extend the interactive palette with host-registered custom nodes:
import {
UiCustomNodeRegistryProvider,
TimbalRuntimeProvider,
Thread,
} from "@timbal-ai/timbal-react";
<UiCustomNodeRegistryProvider renderers={{ "price-card": PriceCard }}>
<TimbalRuntimeProvider workforceId="...">
<Thread />
</TimbalRuntimeProvider>
</UiCustomNodeRegistryProvider>API reference
TimbalChat props
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) |
| attachments | boolean \| { uploadUrl?, accept? } \| AttachmentAdapter \| null | off | true or a config object enables the built-in upload adapter; null disables; omitted = off |
| attachmentsUploadUrl | string | — | Shorthand: enables the default adapter with a custom upload URL |
| attachmentsAccept | string | — | Shorthand: MIME accept for the default adapter |
| debug | boolean | false | Log every parsed SSE event to the console with a [timbal] prefix |
| 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 |
| showWelcomeSuggestions | boolean | true (default variant), false (panel) | Show built-in welcome suggestions when suggestions is set |
| composerPlaceholder | string | "Send a message..." | Composer input placeholder |
| components | ThreadComponents | — | Override individual UI slots |
| onArtifactEvent | (event: UiEventEnvelope) => void | — | Called when a ui artifact fires an emit action |
| maxWidth | string | "44rem" | Max width of the message column |
| className | string | — | Extra classes on the root element |
Thread props
Same as TimbalChat minus workforceId, baseUrl, and fetch (those live on TimbalRuntimeProvider).
TimbalRuntimeProvider props
| 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 |
| attachments | same as TimbalChat | off | Enable uploads on the runtime (usually set on TimbalChat instead) |
| attachmentsUploadUrl | string | — | Shorthand upload URL for the default adapter |
| attachmentsAccept | string | — | Shorthand MIME accept for the default adapter |
| debug | boolean | false | SSE debug logging (see above) |
TimbalChatShell props
Extends all TimbalChat props except workforceId is optional.
| Prop | Type | Default | Description |
|---|---|---|---|
| workforceId | string | auto from API | When set, skips fetching and hides the built-in selector |
| brand | ReactNode | — | Logo or title at the start of the header |
| headerActions | ReactNode | — | Trailing header content (logout, theme toggle, etc.) |
| hideWorkforceSelector | boolean | false | Hide the built-in <select> even when multiple agents exist |
| className | string | — | Classes on the outer h-screen flex container |
| headerClassName | string | — | Classes on the header bar |
Auth
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.
Setup
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.
Embedding in an iframe
When the app runs inside an iframe, SessionProvider detects embedding and skips the normal cookie refresh flow. Instead:
- The child posts
{ type: "timbal:request-session" }towindow.parent. - The parent responds with
{ type: "timbal:auth", token: "<access>", refreshToken?: "<refresh>" }. - Tokens are stored in localStorage and
fetchCurrentUser()runs as usual.
useSession() exposes isEmbedded: boolean so you can adjust UI (e.g. hide logout redirects that assume a top-level window).
// Parent page
iframe.contentWindow?.postMessage(
{ type: "timbal:auth", token: accessToken, refreshToken },
"*",
);
window.addEventListener("message", (e) => {
if (e.data?.type === "timbal:request-session") {
// inject tokens as above
}
});useSession hook
Access the current session anywhere inside SessionProvider:
import { useSession } from "@timbal-ai/timbal-react";
function Header() {
const { user, isAuthenticated, isEmbedded, 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>
);
}authFetch
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();
}Auth prop reference
| 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 |
Other exports
Components
| Export | Description |
|---|---|
| TimbalStudioShell | Floating sidebar + top bar + full-height TimbalChat. Most opinionated layout |
| StudioSidebar | App nav sidebar — collapses, mobile drawer, runtime portal anchor. variant="flush" full-height rail (default) or "floating" studio card |
| ModeToggle | Sun/moon theme toggle styled for the studio top bar |
| TimbalMark | Liquid-metal brand mark — drop-in welcome icon |
| StudioWelcome | Welcome screen with TimbalMark + staggered intro animation |
| TimbalChatShell | Header + workforce picker + full-height TimbalChat |
| Thread | Full chat UI — messages, composer, attachments, action bar |
| Composer | Standalone composer bar (for custom thread layouts) |
| Suggestions | Suggestion chip grid/row; use with useResolvedSuggestions |
| WorkforceSelector | Styled native <select> for agent switching |
| MarkdownText | Markdown renderer with GFM, math (KaTeX), and syntax highlighting |
| ToolFallback | Animated "Using tool: …" indicator shown while a tool runs |
| ARTIFACT_AGENT_INSTRUCTIONS | Markdown block to paste into agent system prompts |
| ArtifactRegistryProvider | Scope custom artifact renderers |
| UiEventProvider | Low-level provider for ui artifact emit actions |
| UiCustomNodeRegistryProvider | Register { kind: "custom" } node renderers |
| ArtifactView | Render a single artifact object |
| parseArtifactFromToolResult | Parse tool output into an artifact |
| TooltipIconButton | Icon button with a tooltip |
Hooks
| Export | Description |
|---|---|
| useWorkforces | Fetch {baseUrl}/workforce and track selection |
| useTimbalStream | Low-level SSE chat state without <Thread> |
| useTimbalRuntime | Access runtime context inside custom providers (incl. loadMessages) |
| useConversations | List past conversations (thread roots) for a workforce |
| useConversation | Load one thread's turns + reconstruct ChatMessage[] from traces |
| useResolvedSuggestions | Resolve static/async SuggestionsSource to an array |
| useOptionalSession | Same as useSession but returns null when no SessionProvider is mounted |
UI primitives
Radix-backed wrappers pre-styled with the design tokens (bg-popover, border-border, shadow-card, …) — import from @timbal-ai/timbal-react/ui or the root. Use these instead of npx shadcn; raw shadcn references token names the app doesn't define and renders unstyled.
Control-surface contract. Every input, select / dropdown trigger, and search field shares one skin so they match side by side regardless of origin. Build custom controls by composing it — never hand-roll a rounded-* border-input bg-… surface:
import { controlClass, overlaySurfaceClass, overlayItemClass } from "@timbal-ai/timbal-react/ui";
<input className={controlClass({}, "w-full")} /> {/* field shape, h-10 */}
<button className={controlClass({ shape: "pill", size: "sm" })} /> {/* chrome pill */}controlClass({ size, shape }) — size: "sm" | "default"; shape: "field" (rounded-lg, default) or "pill" (chrome rows). Floating panels (popover, menu, listbox) compose overlaySurfaceClass + overlayItemClass. Vendoring a new shadcn primitive means swapping its inline surface string for these before shipping.
- Button:
Button - Dialog:
Dialog·DialogTrigger·DialogContent·DialogTitle·DialogDescription·DialogHeader·DialogFooter·DialogClose·DialogOverlay·DialogPortal - Dropdown menu:
DropdownMenu·DropdownMenuTrigger·DropdownMenuContent·DropdownMenuItem·DropdownMenuCheckboxItem·DropdownMenuRadioGroup·DropdownMenuRadioItem·DropdownMenuLabel·DropdownMenuSeparator·DropdownMenuShortcut·DropdownMenuGroup·DropdownMenuSub·DropdownMenuSubTrigger·DropdownMenuSubContent - Popover:
Popover·PopoverTrigger·PopoverContent·PopoverAnchor - Select:
Select·SelectTrigger·SelectValue·SelectContent·SelectItem·SelectGroup·SelectLabel·SelectSeparator·SelectScrollUpButton·SelectScrollDownButton - Tooltip:
Tooltip·TooltipTrigger·TooltipContent·TooltipProvider - Avatar:
Avatar·AvatarImage·AvatarFallback - Form:
Input·Textarea·Label·Checkbox·Switch·RadioGroup·RadioGroupItem·Form(+ field/item/label/control/message/submit) - Navigation / chrome:
Breadcrumb(+ list/item/link/page/separator/ellipsis) ·Pagination(+ content/item/link/previous/next/ellipsis) ·Menubar(+ sub-parts) ·NavigationMenu(+ sub-parts) ·Toolbar(+ button/separator/toggle/link) - Command & date:
Command(+ dialog/input/list/group/item/…) ·Calendar·Combobox(Popover + Command) ·DatePicker(Popover + Calendar) - Input OTP:
InputOTP·InputOTPGroup·InputOTPSlot·InputOTPHiddenInput·InputOTPSeparator(Radix OTP field via unifiedradix-ui) - Misc:
Kbd·KbdGroup·Shimmer - Feedback / data:
Slider·Progress·Badge - Overlays:
Sheet·AlertDialog·HoverCard·ContextMenu(+ sub-parts) ·Toast/Toaster/toast()/useToast - Surfaces:
Card(+ header/footer/title/description/content) ·Alert·Skeleton·Table(+ header/body/row/cell) - Toggles:
Toggle·ToggleGroup·Collapsible·ScrollArea - Section navigation (pill switcher):
PillSegmentedTabs·SubNav(app kit) — not shadcnTabs; RadixTabsis not exported - Layout / disclosure:
Accordion·AccordionItem·AccordionTrigger·AccordionContent·Separator·AspectRatio - Input chrome:
InputGroup(+ addon/control/text) ·Spinner - More primitives:
AvatarGroup(overflow stack) ·Stepper(wizard progress) ·Timeline(event rail) ·Rating(stars) ·NumberField(stepper input) ·TagInput(chips) ·Banner(page notice) ·CopyButton·Snippet(code + copy) ·CircularProgress(SVG ring) — all dependency-free, on the shared tokens / control surface Kanban(drag-and-drop board) — accessible columns + cards on@dnd-kit(pointer and keyboard sensors, cross-column moves, empty-column drop zones, drag overlay). Controlled (columns+onColumnsChange) or uncontrolled (defaultColumns);onMovereports{ card, from, to }. Variants:density, columntone,cardVariant. Requires the peer deps@dnd-kit/core,@dnd-kit/sortable,@dnd-kit/utilities.
Motion is built in. Dialog, AlertDialog, Sheet, Popover, DropdownMenu, Select, Tooltip, Toast, NavigationMenu, and Accordion / Collapsible animate out of the box (fade / zoom / slide / height). The animation engine is inlined in styles.css — no tailwindcss-animate / tw-animate-css dependency and no consumer config. Duration flows from any duration-* utility (--tw-duration, default 150ms). When composing a custom overlay, reuse overlayAnimationClass rather than adding another animation library.
All primitives are Radix-backed (via the unified radix-ui package) or thin wrappers (cmdk for Command, react-day-picker for Calendar) and styled with the design tokens + the control-surface contract, so a new primitive matches the rest on arrival. Browse them live in the app-kit example: the UI primitives library (per-family audit) and the Blocks library (composed sections — Project settings, Confirm flow, Detail sheet, Empty states, Sign-in).
Site kit (@timbal-ai/timbal-react/site)
Expressive motion & interaction primitives for marketing, brand, landing, and editorial pages — the counterpart to the /app dashboard kit. They animate whatever you put inside them (mechanics, not art direction), are reduced-motion-aware and SSR-safe, and build on the bundled motion engine (no extra dependencies).
import { Reveal, TextReveal, Parallax, Marquee, Magnetic } from "@timbal-ai/timbal-react/site";Reveal— fade/slide a block in as it scrolls into view.variant(fade/fade-up/fade-down/fade-left/fade-right/blur/scale/mask-up),delay,duration,distance,amount,repeat,as.TextReveal— editorial headline entrance; a string child rides up token-by-token from a clip.splitBy(words/lines),stagger,delay,duration,amount,repeat,as(span/h1–h4/p).Parallax— translate a layer relative to scroll for depth.speed(-0.6…0.6),axis(x/y),smooth.Marquee— seamless infinite scrolling row (logo walls, tickers).speed,direction,pauseOnHover,gap.Magnetic— pointer-following affordance for a single interactive child (primary CTA / nav).strength,max,spring.
Motion tokens EASE, DURATION, and SPRING are exported for custom motion work that should match the kit's feel. For codegen agents, inject SITE_AGENT_INSTRUCTIONS (component menu + dosing guidance) into the system prompt:
import { SITE_AGENT_INSTRUCTIONS } from "@timbal-ai/timbal-react/site";Use /site for the marketing/brand surface — not dashboards (/app) or in-chat widgets (artifacts).
Full example
App shell with optional auth, using TimbalChatShell (agent list + chat in one component):
// src/App.tsx
import {
SessionProvider,
AuthGuard,
TooltipProvider,
TimbalChatShell,
} 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>
);
}// src/pages/Home.tsx
import { TimbalChatShell, Button, 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, isAuthenticated } = useSession();
return (
<TimbalChatShell
brand={<span className="text-sm font-semibold">My App</span>}
headerActions={
isAuthEnabled && isAuthenticated ? (
<Button variant="ghost" size="icon" onClick={logout} aria-label="Log out">
<LogOut className="size-4" />
</Button>
) : null
}
welcome={{ heading: "How can I help you today?" }}
suggestions={[
{ title: "Summarize this week", description: "Recent activity at a glance" },
{ title: "What can you help with?" },
]}
attachments
debug={import.meta.env.DEV}
/>
);
}For a fully custom header, combine useWorkforces with TimbalChat instead of TimbalChatShell (see Workforce list hook).
Dashboard + floating copilot (./app)
AppCopilot is a self-contained, drop-in assistant. Render it anywhere inside your app — it portals a fixed glass panel + trigger to document.body, owns its own open/expand state, and mounts the conversation runtime. AppShell is layout-only and has no chat* props; the two are independent.
import { AppShell, AppCopilot, Page, StudioSidebar } from "@timbal-ai/timbal-react/app";
export function OperationsApp() {
return (
<>
<AppShell sidebar={<StudioSidebar /* … */ />}>
<Page title="Operations" actions={<ModeToggle />}>{/* dashboard */}</Page>
</AppShell>
{/* Floating copilot — no AppShell wiring. */}
<AppCopilot
workforceId="your-workforce-id"
context={{ page: "Operations", tab: "overview" }}
/>
</>
);
}AppCopilot renders a rounded floating panel (bottom-right) and a text-only pill trigger — no sidebar column, no chat icons. Global actions like the theme toggle go in Page.actions, not a hand-rolled topbar.
Draggable trigger — the pill can cover UI that lives near the bottom-right corner, so users can drag it anywhere on screen. The spot persists across reloads (localStorage), stays clamped inside the viewport on resize, and dropping the pill back near its home corner snaps it to the default position. Opt out with triggerDraggable={false}, or reset programmatically via useCopilot()?.resetTriggerPosition().
Custom trigger — drive open state via props (open / onOpenChange + hideTrigger), or wrap the app in <CopilotProvider> and call useCopilot() from any button.
| Piece | Role |
|-------|------|
| AppCopilot | Drop-in floating copilot — portals its own overlay + trigger; context prop feeds useAppCopilotContext for agent tooling |
| CopilotProvider / useCopilot() | Optional app-level open/expand state for custom triggers anywhere in the tree |
| LineAreaChart | Cartesian engine on recharts — area/line/bar, stacked, horizontal bars, layout (flush hides axes; tooltips carry category + values), showXAxis / showYAxis, monotone/linear/step, shadcn tooltips (dot/line/dashed) |
| PieChart / RadialChart / RadarChart | Pie & donut (center KPI), concentric progress rings, and spider charts — native shadcn/recharts charts |
| ChartContainer / ChartTooltip / ChartTooltipContent / ChartLegend / ChartLegendContent (/ui) | shadcn chart primitives (recharts wrappers); colors flow from ChartConfig → --color-* |
| Sparkline | Tiny inline trend for table cells and tiles |
| MetricRow | Platform KPI strip in one card (overview metrics, no chart) |
| MetricChartCard | MetricRow + selectable flush chart (no axis ticks; hover for category + value) |
| ChartPanel artifact | Title row + flush plot around a ChartArtifact (chartType: bar/horizontalBar/line/area/pie/donut/radial/radar; optional showAxes) |
| SettingsSection / FieldRow / DangerZone / FloatingUnsavedChangesBar | Two-column settings page building blocks |
| IntegrationCard / ConnectionRow / PlanBadge / IntegrationsEmptyState | Integration catalog + connected list |
| InfoCard / DescriptionList / ExpandableSection / ResourceCard / StatusDot | Surfaces & detail views |
| FieldTextarea / FieldSelect / FieldSwitch | Settings forms matching FieldInput |
| AppConfirmDialog | Delete/export confirmations |
Importable blocks + machine-readable catalog
Common sections ship as importable, prop-driven blocks so agents reuse them instead of rebuilding: FilteredDataTable, StatGrid, IntegrationsGrid, ResourceGallery, SettingsLayout (from /app). Every primitive and block is indexed in APP_KIT_CATALOG — each entry carries an exact importFrom path, what it composedOf, and a source reference to fork when a block doesn't quite fit:
import { APP_KIT_CATALOG, getCatalogEntry, FilteredDataTable } from "@timbal-ai/timbal-react/app";
getCatalogEntry("filtered-data-table")?.importFrom; // "@timbal-ai/timbal-react/app"Inject APP_KIT_AGENT_INSTRUCTIONS into codegen / workforce prompts (same idea as ARTIFACT_AGENT_INSTRUCTIONS). Its block/primitive listing is generated from APP_KIT_CATALOG, so the import paths it gives agents never drift. Agents should compose creatively from the component menu — not clone a single demo layout.
import { APP_KIT_AGENT_INSTRUCTIONS } from "@timbal-ai/timbal-react/app";
const systemPrompt = `${basePrompt}\n\n${APP_KIT_AGENT_INSTRUCTIONS}`;| Examples | Purpose |
|----------|---------|
| examples/app-kit/src/recipes/ | Short patterns (metrics, table, forms, copilot, …) — preferred for agents |
| examples/app-kit/src/reference/ | One full wired dashboard — reference only |
Browse locally: examples/app-kit (bun run example:app).
Migrating from 0.5 to 0.6
- Source layout is now grouped by domain:
src/chat/,src/studio/,src/app/,src/ui/. - Optional subpath imports:
@timbal-ai/timbal-react/chat,/studio,/ui,/app. - Message column helpers moved to the library: import
threadMessageColumnClassfrom./chatinstead of copying class strings. - New app kit (
./app):AppShell,Page,StatTile,DataTable, etc. Seeexamples/app-kit. - Newly exported types:
ThreadVariant(main entry) and the content-part typesContentPart,TextContentPart,ToolCallContentPart,ThinkingContentPart(from./chat).
The main entry still exports the high-level surface (TimbalChat, Thread, the runtime hooks, …), so existing import { TimbalChat } from "@timbal-ai/timbal-react" apps keep working. Some lower-level helpers now live only on the subpath entries (./chat, ./ui, ./studio, ./app).
Migrating from 0.4 to 0.5
0.5.0 slims the public API to the surface that blueprint apps actually use. Every feature still works — only the export list changed.
Re-brand via CSS variables, not internals
All sizing and class composites moved to internal modules. Override the CSS variables in your own :root / .dark blocks instead of importing helper strings:
:root {
--studio-sidebar-width: 15rem;
--studio-topbar-height: 3.25rem;
}For colours, override the existing semantic tokens (--background, --foreground, --composer-bg, --bubble-user, --playground-from/via/to, …). See src/styles.css for the full list.
Removed from the public API
The following symbols are no longer exported. Most have no replacement because they were never meant to be public; for the rest, prefer the high-level shells or CSS-variable overrides:
- All
STUDIO_*layout constants (STUDIO_SIDEBAR_WIDTH,STUDIO_INSET_LEFT,STUDIO_SIDEBAR_COLLAPSED_STORAGE_KEY,STUDIO_SIDEBAR_PX_*, …) — override the matching--studio-*CSS variables instead. - All
studio*Class/studioChromeShellStylehelpers — re-create the look with normal Tailwind classes against semantic tokens (bg-elevated-from,border-border,shadow-card, …). TimbalV2Button— removed for good in 3.0.0. Use the standardButtonexport from this package; it covers the same variants and renders the pill chrome viashape="pill". (SeveralTIMBAL_V2_*surface token records — modal/card/switch/avatar chrome — remain public exports for catalog/list surfaces; only the button component is gone.)StudioSidebarPanel,StudioSidebarHeader/Nav/Footer/Entries/Backdrop/Tooltip/RuntimePortal/EntryMotion,StudioSidebarContext,useStudioSidebarLayout,useStudioSidebarCollapsed,useSidebarCollapsePhase,workforceItemId/Label/Initial— useStudioSidebarorTimbalStudioShelldirectly.runThemeSanityCheck—<Thread>already schedules the dev-only check.SyntaxHighlighter,UserMessageAttachments,ComposerAttachments,ComposerAddAttachment,MessagePartPrimitive,ActionBarMorePrimitive,ErrorPrimitive,useAuiState,buttonVariants— internal composer/markdown details. Override theComposer/AssistantMessageslot via thecomponentsprop if you need a custom layout.
Everything else (the three shells, primitives, hooks, auth, artifact API, design-token CSS variables) is unchanged.
Examples
examples/mock-ui— chat + artifact gallery (mockfetch).examples/app-kit— app kit recipes + optional reference dashboard (seeAPP_KIT_AGENT_INSTRUCTIONS).
Mock UI demo
An offline Vite app lives in examples/mock-ui. It uses a scripted mock fetch (no API keys) and includes a component gallery for artifacts. See that folder’s README for run instructions.
Local development
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 dist/ (Vite does not read src/):
cd timbal-react
bun run build # one-off build
bun run build:watch # rebuild on every changeVite apps linked with file:../timbal-react
Without extra config, Vite pre-bundles a cached copy under node_modules/.vite/deps and your UI can look stuck on an old build.
- Add the local-dev plugin in
vite.config.ts:
import { timbalReactLocalDev } from "@timbal-ai/timbal-react/vite";
export default defineConfig({
plugins: [timbalReactLocalDev(), /* react(), … */],
});For file: / symlink installs the plugin aliases package entrypoints to src/ and watches both src/ and dist/, so app-kit / blueprint dev reflects timbal-react edits without rebuilding dist/ on every change.
- Run dev (from your app or repo root):
bun run example:app # timbal-react root — builds dist in watch mode + Vite
# or
node ../timbal-react/scripts/dev-linked.mjs viteRun bun run build in timbal-react before publishing or to verify the production bundle.
One-time if you still see stale UI: rm -rf node_modules/.vite then restart dev.
