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

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

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

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

  1. Backend proxy. Run the SDK on your server; have your frontend call your server. Your server holds the key.
  2. Trusted internal tools. Employee dashboards behind SSO are usually fine.
  3. Server-issued tokens. Not supported yet, reach out if you need them.