@darqlabs/curator-sdk-react
v0.14.0
Published
Official React components for Curator — drop-in chat widget and headless hook backed by @darqlabs/curator-sdk.
Maintainers
Readme
@darqlabs/curator-sdk-react
Official React components for Curator. Drop in a ready-to-use chat widget that talks to your deployed agents, or wire up your own UI with the headless hook.
Beta. The Curator SDKs are in beta. APIs and behavior may change in future versions, including breaking changes to existing behavior, until a 1.0 release. Pin an exact version and review the release notes before upgrading.
Powered by assistant-ui internals: token-by-token streaming, edit/regenerate primitives, and tool-call extension points are available to consumers who want to go deeper than the drop-in widget.
// once, at the app root
import { Curator } from "@darqlabs/curator-sdk"
import { CuratorProvider } from "@darqlabs/curator-sdk-react"
const curator = new Curator({
apiKey: "CURATOR_API_KEY",
project: "default",
environment: "production",
})
export function App() {
return (
<CuratorProvider client={curator}>
<Routes />
</CuratorProvider>
)
}
// anywhere inside
import { CuratorChat } from "@darqlabs/curator-sdk-react"
export function Support() {
return <CuratorChat agent="support-bot" />
}Install
npm install @darqlabs/curator-sdk @darqlabs/curator-sdk-reactReact 18 or 19. Works with Vite, Next.js, Remix, anything with react and react-dom.
Why pass the client?
The components consume a Curator instance (built from @darqlabs/curator-sdk) rather than an API key. That keeps auth, project / environment defaults, custom fetch, and headers in one place. The same instance can power multiple chats, file uploads, or your own SDK calls.
Wrap your tree once with <CuratorProvider> and components further down read the client from context automatically. Or pass curator explicitly to override (e.g. when one screen needs to talk to a different client).
Drop-in component
<CuratorChat
agent="support-bot"
// optional, overrides the provider's client
curator={otherCurator}
// optional UX
title="Support"
placeholder="Ask anything…"
welcomeMessage="Hi! How can I help?"
assistantLabel="Support agent" // name beside the avatar on each reply (default "Assistant")
// optional locator overrides
project="default"
environment="production"
conversationId={savedConversationId}
// optional message actions (both default off)
enableRetry // ↻ regenerate button under the latest assistant reply
enableCopy // copy button on assistant messages
// optional styling
theme="light" // or "dark"
className="my-chat"
style={{ height: 520 }}
// optional events
onMessage={(msg) => console.log(msg)} // final assistant message (msg.metadata = last state snapshot)
onState={(s) => console.log(s.metadata)} // live structured state, every turn (see below)
onPlanProgress={(p) => console.log(p.message)} // planned-run progress (see below)
onError={(err) => console.error(err)}
/>Driving the composer from your page (ref)
<CuratorChat> accepts a ref with three methods:
| Method | Effect |
| --- | --- |
| send(text) | Appends a user turn to the thread the person is looking at, exactly as if they had typed and submitted it. The reply streams into the same chat, with the same history, files and MCP App plumbing. |
| focus() | Moves keyboard focus into the composer. |
| stage(text) | Puts text in the composer and focuses it, without sending. For an intent the person should finish themselves. |
const chat = useRef<CuratorChatHandle>(null)
<CuratorChat ref={chat} agent="booking-bot" />
<button onClick={() => chat.current?.stage("Change the delivery window to ")}>Change delivery window</button>Prefer stage over send for page controls: a canned sentence dropped into the transcript reads as something the person did not write.
Older history
Reattaching with conversationId replays the most recent page of the conversation (historyLimit, default 50). Scrolling to the top of the transcript fetches the page before it, and so on to the beginning: a small "Loading earlier messages…" row shows while a page is in flight, the reader's place is held still as the older messages are inserted above, and the row disappears once there is nothing older. A page never loads while a reply is streaming. Where IntersectionObserver is unavailable, the row is a "Load earlier messages" button instead. Older pages render as prose and cards; an interactive view whose session has expired shows its expired card, as it does today on reattach.
Workspace mode: contextual composer, activity, tool calls
For a task agent (booking, quoting, filing) the transcript is supporting context and the task is the primary object. Three props let the page around the widget coordinate with the conversation surface without owning the renderer.
Contextual composer. Clicking a progress row on your page ("References") should focus a prompt for that thing, not insert a chat message. Hand the composer a context: the placeholder changes, a small label names the mode, and values already captured show as removable chips. Everything is controlled by you; clear it with null when the mode ends. Escape on an empty composer, or the ✕ beside the label, calls onDismiss.
const [mode, setMode] = useState<CuratorComposerContext | null>(null)
<CuratorChat
ref={chat}
agent="booking-bot"
placeholder="Tell the booking agent what to add or change…"
composer={mode}
/>
<ProgressRow
label="References"
onClick={() => {
setMode({
label: "References",
placeholder: "Enter PO, order, or reference numbers…",
chips: draft.references.map((r) => ({ id: r, label: r })),
onRemoveChip: (chip) => removeReference(chip.id),
onDismiss: () => setMode(null),
})
chat.current?.focus()
}}
/>Activity line. A one-line status above the composer, tied to what your page is doing rather than to a message: activity={{ text: "Updating price…" }}, then { text: "Ready to book", state: "done" }, then null. state is working (spinner, the default), done (check) or error (warning mark). It is a polite live region, so screen readers hear the change.
Tool calls. Plain (non-app) tool calls the agent makes during a turn are drawn in the transcript as compact activity rows: "Get price…" with a spinner while the call runs. By default a finished call leaves no row behind — an agent that runs six lookups per turn would otherwise leave six lines of noise under every reply. activityLabel is your policy for that:
<CuratorChat
agent="booking-bot"
activityLabel={(call) => {
// call: { capabilityId, toolName, title, phase: "running" | "completed" | "failed", arguments?, result?, reason? }
if (call.capabilityId.endsWith(".add_accessorial") && call.phase === "completed")
return `Accessorial added · ${labelFor(call.arguments?.code)}` // keep this one in the record
if (call.phase === "failed") return `${call.title} did not complete`
return undefined // default: name while running, hidden once done
}}
onToolCall={(call) => {
// Fires on started / completed / failed for every call, app views included.
if (call.capabilityId.endsWith(".add_accessorial")) setSectionBusy("accessorials", call.phase === "started")
}}
/>Return a string to show that text, null to hide the row, undefined for the default. Match calls on capabilityId (mcp.<server>.<tool>, builtin.<name>), not toolName: the stream's tool_name carries the attachment's description override when an admin wrote one, so it can be a sentence. title is the server's declared title for an app view, else the capability id humanized. arguments and result are present only where the deployment's admin exposed the tool's call data. onToolCall is the hook for your own panes: highlight the section a call is changing, show "Updating…" beside it, and clear it when the call settles — no extra transcript message.
Assistant replies are drawn directly on the surface under a small marker row (avatar, assistantLabel, time); cards are reserved for interactive views, quotes, warnings and outcomes. Pass assistantStyle="bubble" to keep the earlier filled-bubble presentation, and assistantAvatar to replace the mark.
Motion is deliberate and short: rows and statuses change in about 200 ms, content and frames in about 300 ms, a completed check draws itself in, and nothing types itself out. prefers-reduced-motion turns all of it off except the spinner, which is the only thing saying "still working".
Live structured state (onState)
If your agent produces a structured result (an itinerary draft, an extracted form, a plan), it can report that result as it works, so your app renders progress live instead of waiting for the final message. On the agent side this is the reserved __emit_state tool (call it each turn with the complete current object); on the client side it surfaces as onState.
onState fires on every turn the agent reports state (including mid-run, while it's still calling tools), before onMessage. state.metadata is the complete current snapshot, schema-agnostic; validate/shape it in your app.
function TripPlanner() {
const [itinerary, setItinerary] = useState(null)
return (
<>
<CuratorChat
agent="trip-planner"
onState={(s) => setItinerary(s.metadata?.itinerary)} // updates as the agent fills it in
/>
<ItineraryPreview itinerary={itinerary} />
</>
)
}The same snapshot also arrives on the final message as msg.metadata via onMessage, so a non-streaming consumer still gets the last state without handling onState.
Planned-run progress (onPlanProgress)
Some agents decompose a complex request into a task plan and work through it before answering. Such a run is quiet until the final answer streams, so pass onPlanProgress to show progress while it works. It fires through phase (planning → executing → finalizing) with a user-facing message and, once tasks are running, completed/total.
function SupportChat() {
const [status, setStatus] = useState<string | null>(null)
return (
<>
<CuratorChat
agent="research-bot"
onPlanProgress={(p) => setStatus(p.message)} // "Planning the work…", "Completed 2 of 5"
onMessage={() => setStatus(null)} // clear when the answer arrives
/>
{status && <ProgressBanner text={status} />}
</>
)
}Non-planned agents never emit it, so adding the handler is always safe.
onPlanProgress is also available on <CuratorChatLauncher />.
Interactive MCP Apps (mcpApps)
An MCP tool attached to the agent may ship its own view (the MCP Apps extension: a ui:// HTML resource declared in the tool's _meta). When the deployment's admin has exposed that tool's call data, the agent's call renders as an interactive view inline in the transcript — a customer picker, a form, a map — sandboxed on a separate origin. Nothing to configure on your side; it is on by default.
<CuratorChat
agent="booking-bot"
mcpApps={{
maxHeight: 520, // cap on a view's height (default 480)
onModelContextUpdate: (ctx) => log(ctx), // `ui/update-model-context` from a view, surfaced to you
afterMessage: "collapse", // after a view sends a message: "keep" (default) the frame, or collapse it to a card of what it sent
}}
/>The widget declares clientCapabilities: { mcpApps: true } on every send (SDK ≥ 0.8.0); the server mints app sessions and steers the agent's prose around the view only for clients that declare it. Pass mcpApps={{ enabled: false }} to declare the opposite and show a static card instead of a view. Views are minted on the live streaming response. A conversation reloaded from history renders a view again while its session is still live (30 minutes by default, MCP_APP_SESSION_TTL_MINUTES on the server) for the same end user, with the arguments and result it was shown; after that it shows an "Interactive view expired" card where the view was. A turn a view sends on the person's behalf is labelled "via " in the transcript — it is app-reported, not typed.
A live view is drawn with one clear boundary so it reads as something to act on rather than another bubble. By default (variant: "edge") that is a single accent edge down the left of a raised surface — no ring, no nested rectangles, no border around the view's own border. Tune it with mcpApps.frame:
<CuratorChat
agent="booking-bot"
mcpApps={{
frame: {
variant: "edge", // or "orbit" (a bright node rides the frame over a glow), "static" (hairline + glow), "none"
accent: "#0f9d6e", // any CSS color; default: the `accent` token
title: "loading", // when the widget's own caption shows above the view: "loading" (default) | "always" | "never"
settleOnInteraction: true,
},
}}
/>The caption (the tool's title) shows while the view loads or if it fails, and goes once the view is ready — a ready view carries its own heading. orbit and static also take glow (0 ring-only to 1) and speed (seconds per orbit); light themes draw the glow denser than dark ones. Once the person interacts with the view (a click or focus inside it, or the view calling a tool or sending a message) the edge softens, or the node stops and the glow halves; pass settleOnInteraction: false to keep it as it was. Users with reduced motion get the still highlight from the start. A host stylesheet can also set --curator-mcp-accent, --curator-mcp-glow and --curator-mcp-speed. Static cards from history carry a muted edge and no highlight.
While a view's HTML is fetched and it completes its handshake, the widget owns the loading state: the edge is on from the first paint, the frame reserves 160px, and a shimmer skeleton fills it; after four seconds the caption says "Still loading the view…". When the view reports ready it rises into place. To draw your own placeholder inside the frame, pass mcpApps.renderLoading={({ title, slow }) => <MySpinner label={title} />}. There is nothing to wrap in Suspense: the load happens inside the sandbox, out of React's sight.
A view may also hand the person a file (ui/download-file): an export it generated, or a signed link it was given. It has no other route — the sandbox runs under connect-src 'none', so the view cannot fetch its own URL. The widget saves it: a link opens in a new tab so the URL's own Content-Disposition decides, and inline bytes go straight to the downloads folder under the name the view chose (a base name only, never a path). There is no confirmation dialog — the person clicked inside the view — but the widget refuses past 8 files per request, 10 MB of inline bytes, or 20 files over one view's life. Take it over to confirm first, or to route the file yourself:
<CuratorChat
agent="booking-bot"
mcpApps={{
onDownloadFile: async ({ files, toolCallId, session }) => {
// files: { kind: "link", url, name, mimeType? } | { kind: "inline", data, encoding, name, mimeType? }
if (!(await myConfirm(files.map((f) => f.name)))) return false // the view sees { isError: true }
await saveWithMyDownloadManager(files)
return true
},
}}
/>Returning false is a decision, not a fault — that is how the spec defines a cancelled confirmation, and a well-behaved view returns the row to rest rather than showing an error.
A view that has sent a message on the person's behalf (a picker reporting the selection) stays on screen by default, and what it shows next is up to its author; some go blank. Pass mcpApps={{ afterMessage: "collapse" }} to tear the frame down at that point and leave a compact card with the first line of what it sent, so a finished choice reads as finished. The message itself is the next user turn either way.
One live view per tool per turn: if the agent calls the same view tool again in one turn (a retry after a rejected argument, say), the earlier call collapses to a card reading "Replaced by a newer view below" and only the latest view is live.
Your page's Content-Security-Policy must allow https://*.scf.auiusercontent.com in frame-src for views to load.
Message actions
| Prop | Default | Effect |
| --- | --- | --- |
| enableRetry | false | Shows a ↻ button under the latest assistant message that re-sends the preceding user turn. |
| enableCopy | false | Shows a copy button on assistant messages (with a brief check-mark confirmation). |
Theming
Two built-in palettes via the theme prop ("light" / "dark"). For finer control, override the design-token CSS variables through the style prop:
<CuratorChat
agent="support-bot"
style={{
["--curator-accent"]: "#2563eb", // agent bubble + send button background
["--curator-accent-text"]: "#ffffff", // agent bubble + send button text/icon
["--curator-user-bubble"]: "#f1f5f9", // user bubble background
["--curator-bg"]: "#fafafa", // chat background
["--curator-text"]: "#18181b", // primary text
["--curator-muted"]: "#71717a", // action icons, timestamps, hints
["--curator-border"]: "rgba(0,0,0,0.08)",
["--curator-error"]: "#b91c1c",
["--curator-radius"]: "16px", // container corner radius
} as React.CSSProperties}
/>| Token | Controls |
| --- | --- |
| --curator-accent / --curator-accent-text | The avatar tint, send button, chips, activity checks, the live view's edge, and the composer's tint (mixed over the surface) |
| --curator-user-bubble / --curator-user-bubble-text | User bubble |
| --curator-assistant-bubble / --curator-assistant-bubble-text | Assistant bubble — only with assistantStyle="bubble"; canvas replies use --curator-text |
| --curator-bg / --curator-surface | Chat background / cards and the base of the composer surface |
| --curator-composer-bg / --curator-composer-shadow / --curator-composer-shadow-active | The floating composer card: its fill (default: the page colour in light, --curator-surface in dark) and its resting and focused elevation |
| --curator-muted | Action icons, welcome text, timestamps, activity rows |
| --curator-border, --curator-error, --curator-radius | Hairlines and rules, error text, container radius |
Use the style prop (not a CSS class or an ancestor rule): the widget sets these variables inline on its own root for the active theme, and inline styles win over a class, and the style prop is applied last, so it's the one override that reliably takes effect. The widget ships no external stylesheet.
Floating launcher
For a chat-bubble-in-the-corner widget instead of an inline panel, use <CuratorChatLauncher />. Same props as <CuratorChat /> plus placement controls:
import { CuratorChatLauncher } from "@darqlabs/curator-sdk-react"
<CuratorChatLauncher
agent="support-bot"
placement="bottom-right" // or bottom-left / top-right / top-left
offset={24} // px from the docked corner
panelWidth={380}
panelHeight={560}
defaultOpen={false}
// all <CuratorChat /> props are forwarded:
title="Support"
theme="light"
welcomeMessage="Hi! How can I help?"
/>Renders via a portal to document.body, so it escapes stacking contexts in the host app. ESC closes the panel; clicking outside doesn't (intentional: it keeps chat state from getting lost on a stray click).
Headless hook
For full UI control, use useCuratorChat directly. curator is optional when a <CuratorProvider> is in scope:
import { useCuratorChat } from "@darqlabs/curator-sdk-react"
function MyChat() {
const { messages, send, sending, error, conversationId } = useCuratorChat({
agent: "support-bot",
})
return (
<div>
{messages.map((m) =>
m.kind === "user" ? <UserBubble key={m.localId} text={m.content} />
: m.kind === "assistant" ? <BotBubble key={m.runId} text={m.content} />
: <ErrorRow key={m.localId} text={m.message} />,
)}
{sending && <Typing />}
<Composer onSubmit={(text) => send(text)} />
</div>
)
}The hook returns:
| | |
| --- | --- |
| messages | Chronological list of bubbles (user / assistant / error). |
| send(content, opts?) | Append a user message and await the assistant's reply. opts.files for attachments, opts.timeoutMs to override the wait. |
| sending | True while a send is in flight. |
| error | Most recent error, or null. Cleared on next successful send. |
| chat | Underlying SDK Chat instance. |
| conversationId | Conversation id once one has been created. |
| loadingHistory | True while the most recent page is being fetched for a reattached conversation. |
| loadOlder() | Fetch the page before the oldest loaded message and prepend it. No-op while nothing is older, a page is in flight, or a send is running. |
| hasOlderHistory | True while there are messages before the oldest one loaded. Hide your "load earlier" control when false. |
| loadingOlder | True while loadOlder() is fetching. |
| reset() | Clear visible messages (keeps the same conversation). |
Reading the client directly
import { useCurator } from "@darqlabs/curator-sdk-react"
function UploadButton() {
const curator = useCurator()
return (
<button onClick={async () => {
const fileRef = await curator.files.upload({ filename, mimeType, data })
// …
}}>
Upload
</button>
)
}Throws a clear error if no <CuratorProvider> is mounted higher in the tree.
Errors
The hook captures all SDK errors into a friendly kind: "error" bubble (auth, agent failures, timeouts, approval-required pauses, etc.), and also exposes the raw error via error and the onError prop so you can log or surface them however you like.
Security in the browser
The chat runs wherever your Curator client runs. If you instantiate the client in browser code, the API key ships in your bundle. Recommended patterns:
- Backend proxy. Run the SDK on your server; have your frontend call your server. Your server holds the key.
- Trusted internal tools. Employee dashboards behind SSO are usually fine.
- Server-issued tokens. Not supported yet, reach out if you need them.
