npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@timbal-ai/timbal-react

v0.2.1

Published

React components and runtime for building Timbal chat UIs

Readme

@timbal-ai/timbal-react

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.

Installation

npm install @timbal-ai/timbal-react
# or
bun add @timbal-ai/timbal-react

Peer dependencies:

npm install react react-dom @assistant-ui/react @timbal-ai/timbal-sdk

Tailwind setup

The 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.

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>
  );
}

TimbalChat requires a fixed height parent. Use height: "100vh" or flex-1 min-h-0 depending 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" },
  ]}
/>

Placeholder and width

<TimbalChat
  workforceId="your-workforce-id"
  composerPlaceholder="Type a question..."
  maxWidth="60rem"
  className="my-custom-class"
/>

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} />

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>

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 | 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.

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 |


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) | | 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 |

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 |


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.

useSession hook

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>
  );
}

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 | |---|---| | 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 |

UI primitives

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


Full example

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>
  );
}

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:

cd timbal-react
bun run build        # one-off build
bun run build:watch  # rebuild on every change

Vite picks up the new dist/ automatically via HMR — no reinstall needed.