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

@filigran/chatbot

v3.14.0

Published

Filigran chat panel — React + Tailwind standalone chatbot component

Readme

@filigran/chatbot

Filigran chat panel — a standalone React + Tailwind chatbot component with SSE streaming, multi-agent support, and full markdown rendering.

Features

  • 🔄 SSE Message Streaming — Real-time response streaming with status indicators
  • ⚡ Mid-Run Steering — Send messages while the agent is generating; they are injected into the running agentic loop instead of waiting for the turn to finish
  • ✋ Tool Approval — When the agent stops at a tool that needs a human's consent, the turn pauses mid-answer and the reviewer approves, declines with a reason, or approves always — opt-in per host, see Tool approval
  • 🗂️ Conversation History — Switch between (and delete) past conversations from a header menu, or from a permanent sidebar in fullscreen mode (collapsible, searchable past 7 entries, rename in place)
  • 🤖 Multi-Agent Support — Switch between different AI agents
  • 🔗 Conversation References — Type @ in the composer to point the agent at another conversation; opt-in per host, see Conversation references
  • 📎 File Attachments — Upload and paste files (PDF, TXT, images)
  • 📥 Agent-Generated Files — Renders downloadable file cards from agent output and strips the [[FILE:id]] markers from the prose
  • 📝 Full Markdown — Tables (mis-delimited ones repaired), code blocks with copy button, lists, blockquotes, soft line breaks, inline images with a lightbox
  • 🖼️ Image Previews — data:image/* charts and image attachments render inline, click to expand
  • 📋 Message Footer — Every message says when it was sent; copy any message, rate an answer 👍/👎 (stored by the backend or handed to the host, with an optional comment on a 👎) and have it read aloud — see Message feedback
  • 🧰 Composer Toolbar — Prompt library and quota indicator, both driven by whether the host serves the route; plus a slot for the host's own controls
  • 🧠 Context Gauge — Ring + percentage showing how full the model's context window is, so a long chat's silent summarising is visible before it happens
  • 🎙️ Dictation — Speech-to-text via the browser's own Web Speech API; no endpoint, no key, hidden where unsupported
  • ✍️ Draft Recovery — Unsent composer text is kept per conversation and restored when the panel reopens
  • 🎨 Customizable Theme — Accent color and logo customization
  • 📱 3 Display Modes — Floating, sidebar (resizable), and fullscreen
  • 💾 Persistence — Conversation and sidebar width saved to localStorage
  • 🛠️ Tool Tracking — Visual indicators for AI tool usage

Installation

yarn add @filigran/chatbot

Quick Start

import { useState } from 'react';
import { ChatPanel, ChatToggleButton } from '@filigran/chatbot';
import '@filigran/chatbot/styles.css';

function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [mode, setMode] = useState<'floating' | 'sidebar' | 'fullscreen'>('floating');

  return (
    <>
      <ChatToggleButton isOpen={isOpen} onToggle={() => setIsOpen(!isOpen)} label="Ask Assistant" accentColor="#7b5cff" />
      {isOpen && (
        <ChatPanel mode={mode} onClose={() => setIsOpen(false)} onModeChange={setMode} apiBaseUrl="/api/assistant" user={{ firstName: 'John' }} />
      )}
    </>
  );
}

Components

ChatPanel

The main chat interface component.

import { ChatPanel } from '@filigran/chatbot';

Props

| Prop | Type | Default | Description | | ------------------- | ----------------------------------------- | ------------ | ---------------------------------------------------------------- | | mode | 'floating' \| 'sidebar' \| 'fullscreen' | required | Display mode | | onClose | () => void | required | Called when close button is clicked | | onModeChange | (mode: ChatMode) => void | required | Called when user switches display mode | | apiBaseUrl | string | required | Base URL for chat API endpoints | | user | { firstName: string } | required | Current user info | | topOffset | number | 0 | Top offset in pixels (for sidebar/fullscreen with fixed headers) | | agentDashboardUrl | string | — | XTM One URL for the "Browse agents" / "Create agent" links and the waiting panel's "Play Space Invaders in XTM One" link ({url}/chat?arcade=play, http(s) only) | | miniGameEnabled | boolean | true | Show the waiting panel during longer waits: rotating messages and the invitation to play XTM One's arcade (the package ships no game of its own) | | onPlayWaitingGame | () => void | — | Open the arcade in place instead of linking to XTM One, for a host that runs it itself (the XTM One floating assistant) | | t | (key: string) => string | identity | Translation function for i18n | | accentColor | string | '#7b5cff' | Primary accent color (hex) | | logoIcon | React.ReactNode | default icon | Custom logo/icon for the assistant | | promptSuggestions | string[] | default list | Prompt suggestions shown on welcome screen | | pageContext | Record<string, unknown> | — | Arbitrary host page context (e.g. { url: '/dashboard/...' }) sent as context on each rest message so the agent knows where the user is. Must be JSON-serializable (skipped if not). Read fresh at send time; omitted when empty. | | resizable | boolean | false | Enable drag-to-resize for sidebar mode | | onWidthChange | (width: number) => void | — | Called when sidebar width changes during resize | | onResizeStart | () => void | — | Called when resize drag starts | | onResizeEnd | () => void | — | Called when resize drag ends | | onMessageFeedback | (id, feedback, message) => void | — | Receives each rating of an answer (null clears it); id is the message's persisted id when the backend reported one (done.message_id, a restored entry's id), else the panel's own. On its own it enables 👍/👎 and the panel stores nothing; with apiEndpoints.feedback set it is called once the backend has stored the rating. With neither, no thumbs. | | locale | string | browser's | BCP 47 language tag the message times are written in and answers are read aloud in (the platform's language, e.g. 'fr-FR') | | disableImagePreviews | boolean | false | Render image attachments as download cards instead of inline previews | | contextUsageEnabled | boolean | true | Show how full the model's context window is for the current conversation (ring + percentage in the composer toolbar). Data-driven, so it stays absent until the backend reports occupancy — see Context usage. | | composerToolbar | React.ReactNode | — | Extra controls appended to the composer toolbar. The escape hatch for host-specific affordances (XTM One's session-tool picker) — the package never learns what they are. Pass nothing and the toolbar simply has none. |

Plain-data props (requestHeaders, apiEndpoints, pageContext, promptSuggestions) are compared by content and callback props are called through a stable wrapper, so building them inline is fine. t and logoIcon are used as given: keep them stable (useCallback / useMemo), or every render of the host re-renders every message in the thread.

Resizable Sidebar Example

function App() {
  const [sidebarWidth, setSidebarWidth] = useState(400);
  const [isResizing, setIsResizing] = useState(false);

  return (
    <div style={{ marginRight: sidebarWidth }}>
      <MainContent />
      <ChatPanel
        mode="sidebar"
        resizable={true}
        onWidthChange={setSidebarWidth}
        onResizeStart={() => setIsResizing(true)}
        onResizeEnd={() => setIsResizing(false)}
        // ... other props
      />
    </div>
  );
}

The sidebar width is persisted to localStorage under the key filigranChatSidebarWidth.

ChatToggleButton

A floating action button to toggle the chat panel.

import { ChatToggleButton } from '@filigran/chatbot';

Props

| Prop | Type | Default | Description | | ------------- | ------------------------- | ------------ | -------------------------------------------------------------------------- | | isOpen | boolean | required | Whether the chat panel is open | | onToggle | () => void | required | Called when button is clicked | | label | string | built-in | Button text, already translated by the host; omit for t('Ask Assistant') | | t | (key: string) => string | identity | Translation function, used for the default label | | accentColor | string | '#7b5cff' | Button background color | | icon | React.ReactNode | default icon | Custom icon |

API Contract

The component expects your backend to implement these endpoints:

GET {apiBaseUrl}/chat/agents

Returns available AI agents.

[
  {
    "id": "agent-1",
    "name": "General Assistant",
    "slug": "general",
    "icon": null,
    "description": "A helpful general-purpose assistant"
  }
]

POST {apiBaseUrl}/chat/sessions

Restores conversation history (and, implicitly, resolves the session).

Request:

{
  "conversation_id": "uuid-here",
  "agent_slug": "general"
}

Response:

{
  "conversation_id": "uuid-here",
  "messages": [
    { "id": "msg-1", "role": "user", "content": "Hello", "created_at": "2026-06-10T08:30:00Z" },
    {
      "id": "msg-2",
      "role": "assistant",
      "content": "Hi! How can I help?",
      "created_at": "2026-06-10T08:30:04Z",
      "feedback": { "rating": "positive", "comment": null }
    }
  ]
}

id, created_at and feedback are optional. id is the message's persisted id, what its rating is stored against (see Message feedback); created_at (ISO 8601) is when it was sent, shown in its footer - a message without one shows no time. feedback is the caller's own rating of an assistant answer: the thumbs are then locked on it ("Feedback already submitted").

The response should echo a conversation_id. When the stored conversation_id no longer exists (e.g. the platform was reset but the browser kept an old id in localStorage), the backend is expected to transparently create a fresh conversation and return its new id with an empty messages array. The component adopts whatever id the response returns (persisting it to localStorage), so subsequent messages are never sent against a dead conversation — which would otherwise fail with a "conversation does not exist" error and force the user to manually start a new conversation. If the endpoint responds with a non-2xx status, the component silently resets the stored id and starts fresh on the next message.

Assistant history messages should echo the same attachments[] array that was sent on the original done event (see Agent-generated file attachments), keyed by file_id. The component re-surfaces the download cards on restore, so omitting them means download cards silently disappear after a page reload even though streaming downloads work:

{
  "messages": [
    {
      "role": "assistant",
      "content": "Here is your export. [[FILE:0f3a...]]",
      "attachments": [
        { "file_id": "0f3a...", "filename": "iocs.csv", "type": "csv", "size": 2048, "content_type": "text/csv", "file_tag": "download_file" }
      ]
    }
  ]
}

GET {apiBaseUrl}/chat/sessions

Lists the user's past conversations for the history menu in the chat header.

Response (a bare array or { "conversations": [...] }):

[
  {
    "conversation_id": "uuid-here",
    "title": "What is the weather?",
    "updated_at": "2026-06-10T08:30:00Z",
    "message_count": 12
  }
]

Selecting a conversation restores it through the existing POST /chat/sessions contract above. The endpoint is fetched lazily when the history menu opens; a backend that doesn't implement it yet (404/405) simply yields an empty list, so the menu shows its empty state instead of breaking the chat. Set apiEndpoints.history to null to hide the history menu entirely, or point it at a dedicated path if your proxy can't route GET on the sessions path.

PATCH {apiBaseUrl}/chat/sessions/{conversation_id}

Renames a conversation. Body: { "title": "..." }. Only reached from the fullscreen sidebar; a backend without the route simply fails the request and the row reverts to its previous title.

With workspaces on, the same route files a conversation: { "workspace_id": "uuid" }, or { "workspace_id": null } to take it out of its workspace. A refusal's detail is shown under the list.

Workspaces

The fullscreen conversation list can be grouped by workspace, the way a desktop assistant groups its projects: one collapsible group per workspace (the collapsed ones are remembered per browser), a conversation started inside a workspace with its group's compose button, a workspace created, renamed or deleted from the list, and a conversation moved between groups from its row menu or by dragging it onto a group.

The feature is off until the host names the route: apiEndpoints.workspaces has no default, for the same reason as apiEndpoints.approve - a proxied host must expose every route below before the panel offers them. A backend that refuses the list (not licensed, an older proxy) also leaves the list flat.

| Request | Purpose | | --- | --- | | GET {apiBaseUrl}{workspaces} | The caller's workspaces: a bare array or { "workspaces": [...] } of { id, name, is_default, is_own, can_manage, is_archived }. Archived ones are left out. | | POST {apiBaseUrl}{workspaces} | Create one: { "name": "..." }. | | PATCH {apiBaseUrl}{workspaces}/{id} | Rename one: { "name": "..." }. | | DELETE {apiBaseUrl}{workspaces}/{id} | Delete one (never offered for the default). Its conversations are expected back in no workspace (workspace_id: null), never moved to a default; the history is reloaded after the delete. | | PATCH {apiBaseUrl}{history}/{conversation_id} | File a conversation: { "workspace_id": "uuid" \| null }. | | POST {apiBaseUrl}{sessions} | Carries workspace_id when the conversation is started inside a workspace. |

GET {history} entries carry workspace_id (null for none). A conversation in no workspace is a normal state, listed under "Not in a workspace": a new conversation starts there unless it is started inside a workspace. A workspace the caller may only read (can_manage: false) gets a group only while it holds one of their conversations, and conversations filed in a workspace the list does not return are grouped under "Other workspaces". XTM One serves all of it at /chat/workspaces and /chat/sessions.

Conversation references

Typing @ in the composer - at the start of the text or after a space, never inside a word or an email (a@b) - opens a menu of the user's other conversations, searched by title or key as they type. A pick inserts @<key> , and the message goes out with the conversations it still references, so the agent reads them; a @word typed by hand stays plain text, and so does a pick whose @key is edited out before sending. The arrows move in the menu, Enter or Tab insert, Escape closes it.

The feature is off until the host names the route: apiEndpoints.conversationReferences has no default, for the same reason as apiEndpoints.approve - a proxied host must expose the route, and forward the new body field, before the composer offers the menu. Unset, @ is plain text and the message body is unchanged.

| Request | Purpose | | --- | --- | | GET {apiBaseUrl}{conversationReferences}?q=<text>&limit=8&exclude=<conversation_id> | The menu: { "conversations": [{ id, title, key, updated_at, is_own }] } (or a bare array), the conversations the user can open, most recent first, that q matches (case-insensitive): every word of q (split on spaces, - and _) is in the title, or the key starts with q - so a key typed back (@hi-bob for "Hi Bob"), or a key that is not made of the title's words (an accent folded, a title without Latin letters), still finds its conversation. q is omitted for a bare @, exclude while the conversation is not created yet. key is what the pick inserts after @, always the backend's own. is_own: false marks a conversation shared with the user ("Shared with you"). | | POST {apiBaseUrl}{messages} | Carries referenced_conversation_ids: string[]: the picks the text still holds, in the order they appear, each once, at most 5. Omitted when there is none. | | POST {apiBaseUrl}{sessions} | A restored user message may carry conversation_refs: [{ conversation_id, title, key }]. |

The references of a sent message show as chips above it, also after a reload. A chip opens its conversation in the panel when the conversation list holds it (the list is read as soon as the thread shows a reference), and is a plain label otherwise - a conversation shared with the user, say. While an answer streams, a message with a reference waits for it instead of steering the running turn: a steer reaches the agent as text alone, so it would never read what the message references. Once the text references five conversations the menu says so instead of offering more. The picks of an unsent draft are kept with its text, so a draft restored when the panel reopens still references what it did.

XTM One serves it at /chat/conversation-references; a host behind its own proxy names its route, relative to its apiBaseUrl (e.g. conversationReferences: '/conversation-references').

DELETE {apiBaseUrl}/chat/sessions/{conversation_id}

Deletes a conversation from the history menu. Any 2xx response counts as success; deleting the active conversation resets the panel to a fresh chat so the next message never targets a dead conversation id.

POST {apiBaseUrl}/chat/messages

Sends a message and streams the response via SSE.

Request:

{
  "content": "What is the weather?",
  "conversation_id": "uuid-or-null",
  "agent_slug": "general",
  "context": { "url": "/dashboard/analyses/reports/<id>/overview" }
}

With conversation references on, a message that references another conversation also carries referenced_conversation_ids.

The optional context object is forwarded verbatim from the pageContext prop (REST backend only) and is omitted entirely when empty. Use it to make the agent aware of the user's current location/page; the shape is up to the host and can be extended later (page title, selected entity, user role, etc.). It must be JSON-serializable — a non-serializable value (circular reference, BigInt, …) is skipped rather than breaking the request.

Response: Server-Sent Events stream with these event types:

data: {"type": "status", "status": "thinking"}
data: {"type": "status", "status": "tool_start", "tools": ["search_web"]}
data: {"type": "status", "status": "analyzing"}
data: {"type": "status", "status": "streaming"}
data: {"type": "stream", "content": "The weather "}
data: {"type": "stream", "content": "today is sunny."}
data: {"type": "done", "content": "The weather today is sunny.", "conversation_id": "new-uuid", "message_id": "msg-2", "tool_names": ["search_web"], "tool_call_count": 1, "iterations": 1, "reasoning": "Let me check the weather data first.", "tool_call_trace": [{"name": "search_web", "input": "{\"query\": \"weather\"}", "output": "Sunny, 24C", "success": true}], "transfer_chain": [{"agent_id": "uuid", "agent_name": "General"}], "is_truncated": false}

The optional message_id on done is the answer's persisted id: without it (and without a restore naming the message) the answer cannot be rated through apiEndpoints.feedback.

The optional reasoning field on done (and on restored session messages) carries the accumulated model reasoning / pre-tool preamble prose for the turn. When present it is surfaced in the per-message reasoning-details dialog (the "i" button), mirroring the XTM One web chat.

The reasoning-details dialog also consumes three more optional done fields (all parsed defensively — older backends without them fall back to the flat tool-name list and the plain "i" affordance):

  • tool_call_trace — array of { name, input, output, success } entries rendered as expandable rows (numbered, success/failure icon, pretty-printed JSON input, output)
  • transfer_chain — array of { agent_id, agent_name } hops rendered as the agent transfer chain
  • is_truncated — true when the agent's iteration budget was exhausted; the message then shows an always-visible amber warning triangle instead of the hover-only "i" and the dialog opens with a "Turn limit reached" banner

The same fields are read from restored session messages, so the dialog survives a page reload.

Internal links

Assistant markdown links are routed through onRelativeLinkClick when they are internal to the host application: relative hrefs (/dashboard/...) and absolute http(s) hrefs on the same origin as the embedding page (backends emit absolute links so they work from any chat surface — when the chatbot is embedded in that very platform, e.g. the OpenCTI link inside OpenCTI, the link is reduced to pathname + search + hash and navigates in-app instead of opening a new tab). All other links open in a new tab.

Agent-generated file attachments

When an agent produces a downloadable file, the done event carries an attachments array and the streamed prose embeds [[FILE:<file_id>]] markers. The component strips those markers and renders a download card per attachment:

{
  "type": "done",
  "content": "Here is your export. [[FILE:0f3a...]]",
  "conversation_id": "uuid",
  "attachments": [
    { "file_id": "0f3a...", "filename": "iocs.csv", "type": "csv", "size": 2048, "content_type": "text/csv", "file_tag": "download_file" }
  ]
}

Each attachment carries only file_id + display metadata — never an absolute download URL. Clicking a card issues:

GET {apiBaseUrl}{apiEndpoints.download ?? '/chat/files'}/{file_id}/download

with credentials: 'include' and your requestHeaders. Point apiEndpoints.download at your own backend proxy so the download is authenticated by your platform (the proxy mints any upstream token server-side) — the user never authenticates to the upstream chat service directly. Set apiEndpoints.download to null to disable download cards.

The /chat/files default applies to REST-style endpoints. In singleEndpoint mode there is no per-path routing, so the default is not applied — download cards stay disabled unless you set apiEndpoints.download explicitly to a proxy route.

Download failures (403/404/5xx/network) are reported through the optional onDownloadError(error, attachment) callback so the host can surface them via its own notification system (the chatbot has no toast surface of its own).

Status values:

  • thinking — Agent is processing
  • tool_start — Agent is using tools (with tools array)
  • tool_heartbeat — Liveness signal during a long tool execution (with tools and elapsed_s). The widget keeps the current status label and renders a live elapsed-time indicator next to it once the execution exceeds ~15 s, so long operations (background tasks, agent consults) never look stuck
  • analyzing — Agent is analyzing tool results
  • composing — Agent is composing the response
  • streaming — Content is being streamed
  • steering — A mid-run steering message is being incorporated (see below)

Error event:

data: {"type": "error", "content": "Something went wrong"}

POST {apiBaseUrl}/chat/messages/steer

Steers the agent mid-run: while a response is still streaming, Enter / the accent Send button dispatches the typed text immediately instead of blocking until the turn finishes. The widget POSTs:

{
  "conversation_id": "uuid-here",
  "content": "Actually, filter by subregion instead",
  "agent_slug": "general"
}

A 2xx response means the message was persisted and will be injected into the running agentic loop at the next iteration boundary. On a non-2xx response or a network error the optimistic user bubble is rolled back and the text is restored into the composer (prepended on its own line if the user already typed something new) — so a backend without steering support degrades gracefully and the message is never silently lost. Set apiEndpoints.steer to null to disable the steering affordances entirely.

Steering only applies to text-only sends on the rest backend with a known conversation_id (the first turn of a fresh conversation only receives its id on done). Sends with attachments keep the legacy wait behavior. Esc stops generating.

Multi-segment responses

A steered turn can produce multiple response segments on one SSE stream: when the steering message arrives too late to be folded into the current pass, the backend completes the current segment (an intermediate done event) and then runs a follow-up pass for the steering message (a fresh thinking status followed by more stream events and a final done). The widget renders each segment as its own assistant message:

data: {"type": "status", "status": "steering"}
data: {"type": "stream", "content": "...current answer keeps streaming..."}
data: {"type": "done", "content": "First segment answer", "conversation_id": "uuid"}
data: {"type": "status", "status": "thinking"}
data: {"type": "stream", "content": "Follow-up answer to the steering message"}
data: {"type": "done", "content": "Follow-up answer to the steering message", "conversation_id": "uuid"}

POST {apiBaseUrl}{apiEndpoints.approve}

Answers a turn that paused because the agent proposed a tool call requiring a human's consent. Opt-in: there is no default path. The widget advertises approval support to the backend only when apiEndpoints.approve is set, and that flag is a promise — a backend that pauses a turn waits indefinitely for a decision, with no timeout. A host that names a path it cannot route would receive the pause, POST the decision into a 404, and hang the turn with the user watching a spinner. Leave it unset and the backend degrades to an ordinary assistant message explaining what it could not run, so an un-updated host keeps working untouched.

When set, supports_tool_approval: true is sent on every rest message body.

Server → client, on the existing SSE stream, alongside stream / status / done:

data: {"type": "approval_required", "conversation_id": "uuid-here", "proposals": [
  {
    "tool_call_id": "call_abc123",
    "tool_name": "opencti_delete_entity",
    "tool_description": "Permanently delete an entity from the platform.",
    "arguments": {"entity_id": "e-123", "cascade": true},
    "input_schema": {"type": "object", "properties": {
      "entity_id": {"type": "string", "description": "Entity to delete"},
      "cascade": {"type": "boolean", "description": "Also delete linked entities"}
    }},
    "source": "integration:opencti"
  }
]}

The turn is not over: no done arrives, the stream stays open and silent (kept alive by SSE : keepalive comment lines, which the reader drops), and the rest of the turn continues on it once a decision is sent. The progress bubble is replaced by the prompt, which renders each argument next to its description from input_schema — cascade: true is unjudgeable on its own, so a prompt showing only names and values would be a rubber stamp.

Client → server, one decision per proposed call:

{
  "conversation_id": "uuid-here",
  "decisions": [
    { "tool_call_id": "call_abc123", "decision": "approve" },
    { "tool_call_id": "call_ghi789", "decision": "reject", "rejection_reason": "Wrong target environment." },
    { "tool_call_id": "call_jkl012", "decision": "approve_always" }
  ]
}

| decision | Effect | | ---------------- | ------------------------------------------------------------------- | | approve | Runs with the arguments exactly as proposed | | reject | Does not run; the agent receives rejection_reason and can adapt | | approve_always | Runs, and saves a standing approval for this user |

Every proposed tool_call_id must appear exactly once — the backend refuses a partial set, because resuming with an undecided call leaves a tool_use block without its tool_result, which the model providers reject outright. The prompt therefore submits itself once the last card is decided. A set containing approve_always waits for an explicit Confirm instead: it is the only verdict whose reach outlives the turn (it applies to the user's unattended scheduled runs too), so the warning has to be read before it is committed.

A decision carries no arguments. Correcting a wrong proposal is what reject with a reason is for — rewriting a call under the agent's name would leave a transcript crediting it with arguments it never chose.

On a non-2xx the prompt stays on screen with the failure noted and the controls re-armed: the turn is still paused either way, so clearing the prompt would strand it with nothing able to answer. A 409 means nothing is waiting any more (the turn finished, was cancelled, or was answered elsewhere); the stream ending then clears the prompt on its own. Stopping the turn also dismisses it — the backend waits indefinitely by design, so abandoning the stream is the reviewer's only other way out.

Proxied hosts: the decision goes through the same fetch path as every other endpoint — relative to apiBaseUrl and honouring requestHeaders — so CSRF wrappers and per-request context headers keep working. A proxy in front of the chat must forward the request body whole (a proxy rebuilding it from a fixed field list silently drops supports_tool_approval), never time out the streaming turn, and pass SSE keepalives through untouched.

Acting on the host page

The same path answers act_on_page (XTM One #5559): with apiEndpoints.approve set and onRelativeLinkClick passed — the host's opt-in, since it hands the panel its navigation — the panel also sends supports_page_actions: true, and the agent may act on the page the panel is mounted in. The host's proxy must forward the approve route's body whole (a page_action answer carries no decisions). Without that opt-in a frame is answered page_actions_not_supported, with the page neither read nor acted on. Each step arrives on the stream as a status frame with event: "page_action_required"; the panel performs it (click, open — a hover, or a click when hovering opens nothing —, type, then Enter only in a field that says it is a search - type="search", role="searchbox", inside role="search", or named like one (a name, id, placeholder, aria-label or title whose whole words include "search", "query", "filter" or "find", and only when it is the form's sole field) - never in a form of one unnamed field, which is as likely a create or rename dialog, or navigate through onRelativeLinkClick — never a page load, which would end the turn) and POSTs { conversation_id, page_action: { action_id, ok, error, url, title, snapshot } }. ok is the action's own outcome, so a step is never retried because reading the page back failed; when that read throws, ok stands and error carries snapshot_failed: … with an empty snapshot. A stopped turn (Stop, New chat, switching conversation) abandons the action instead of acting on the page.

The snapshot (utils/pageSnapshot.ts) lists one line per element a user could act on — [e1] link "Reports" -> /dashboard/analyses/reports, with states such as (selected), (pressed), (menu) for a closed menu or (collapsed) for a closed group — and leaves out the panel itself, hidden and inert content, and every secret value: a type="password" field, and anything whose autocomplete marks it as a password, a one-time code or a card field, so a revealed password is masked too. A list cut off at the size cap ends with [truncated]. A state the host shows by styling alone is invisible to it: mark toggles with aria-pressed, the current item with aria-current, and a clickable card with role="button". A 409 means the turn no longer waits for that action, and the panel drops it.

Actions that would leave the page are refused rather than performed, each answered with ok: false and a reason: a navigate off-site, a click on a link to another origin or one opening a new tab, a type on anything that is not a text field, a read-only field, a masked one (a password, one-time code or card field), a file input (whose chooser is an operating-system window), a target that is disabled - including one disabled by its <fieldset> - and one no longer visible when the action runs. A click never completes a native default that would leave the page: an anchor's navigation and a form's submission are cancelled while the host's own handlers still run. Clicking an in-host link is routed through onRelativeLinkClick rather than clicked, for the same reason a navigate is. An action this version does not know is answered as unsupported_action instead of being dropped, so a backend running ahead of the panel is not left waiting out its timeout.

Recovering a prompt after a page reload

approval_required is a single event on a stream, so a reload loses it — including the tool_call_ids a decision has to name — while the turn goes on waiting for an answer that can no longer be given. To the user that is a chat which simply stopped replying.

Set apiEndpoints.pendingApprovals (XTM One: /chat/conversations) and the panel asks once per conversation, on mount and on every conversation switch:

GET {apiBaseUrl}{apiEndpoints.pendingApprovals}/{conversation_id}/pending-approvals
→ {
    "conversation_id": "uuid-here",
    "proposals": [ /* as the event carried */ ],
    "turn": "running" | "idle"
  }

An empty proposals is the ordinary answer. A non-empty one re-renders the same prompt, and the decision is POSTed exactly as before. Like approve this has no default and is skipped when unset, leaving the live flow untouched.

The recovered turn resumes with the reasoning and tool results it had already produced — but not on a stream: the one it would have reported on died with the old page, so the backend persists the answer and suppresses the live done frame. So after a decision on a recovered prompt the panel shows its ordinary working indicator and polls this route every 5s, using turn as the stop condition: while it reads running it keeps waiting, and the moment it reads idle it re-reads the conversation once — the answer is there. A resumed turn that pauses again on a second gated call is picked up by the same poll, which is the only way that prompt could reach the user with no stream open.

One bound applies server-side: after 30 minutes with no sign of a client and no decision, the turn stops waiting and the pause is discarded. Any request about the conversation counts as a sign, so while a recovered prompt is displayed the panel re-reads this route every 10 minutes purely to say someone is still there — a tab left open makes no requests of its own, and the bound is meant to limit abandonment, never the person deciding.

REST backend only: legacy and ag-ui never meet this gate.

Decks, reports and videos

An answer that made a deck, a report or a video carries a marker where it shows it: [[PRESENTATION:<id>]] ([[PRESENTATION:<id>#/<slide id>]] for one slide), [[REPORT:<id>]], [[VIDEO:<id>]]. The panel sends supports_artifacts: true with every message and session restore of the REST backend, and XTM One then describes each one in the answer's artifacts; the panel draws a card where its marker stands. A marker is never shown as text, a descriptor whose marker is missing renders after the prose, and a marker with no descriptor renders nothing.

| Field | Meaning | | --- | --- | | kind, id, title | presentation, report or video, and what it is called. | | url | Its page in XTM One: the card's Open in XTM One (http(s) only, new tab). | | slide_count, slide_ref | A deck's slides, and the slide its marker named. | | section_count | A report's sections. | | scene_count, duration_ms, status, progress | A video's scenes and length, and its newest render (pending, running, ready, failed, cancelled; progress 0-100). | | downloads | The formats it downloads in: pdf, pptx, pptx_native, mp4. Empty until a video has a finished render. |

The downloads go through the host, like file downloads, once it names the route: apiEndpoints.artifacts has no default, for the same reason as apiEndpoints.approve. Unset, the cards show and link to XTM One, with no download buttons.

| Request | Purpose | | --- | --- | | POST {apiBaseUrl}{artifacts}/{kind}/{id}/download | { "format": "pdf" }: the file, saved under its Content-Disposition name. A failure goes to onDownloadError with the name of the file asked for. | | GET {apiBaseUrl}{artifacts}/{kind}/{id} | The descriptor as of now: a card asks again 10 seconds after each answer, one request at a time, for 15 minutes at most, while its video renders. |

XTM One serves both at /chat/artifacts.

Message feedback

Every message ends with a footer. An assistant answer (once it has finished streaming) shows when it was sent, the "i" reasoning details, Copy response, 👍 / 👎 and Read aloud; a user message shows Copy message and its time, right-aligned. The time is written in the locale prop's language (the time alone today, the date and time before; the full date in the tooltip). Time and "i" are always visible; the other actions appear when the message is hovered or focused, and always on a touch screen. A rating given, or the answer being read, stays visible.

The thumbs render when apiEndpoints.feedback is set or onMessageFeedback is passed. Opt-in: feedback has no default path (the same choice as approve): a host names its route, or the panel stores nothing.

POST   {apiBaseUrl}{apiEndpoints.feedback}/{conversation_id}/messages/{message_id}/feedback
       { "rating": "positive" | "negative", "comment": string | null }
       -> 200 { "rating": ..., "comment": ... }
DELETE {apiBaseUrl}{apiEndpoints.feedback}/{conversation_id}/messages/{message_id}/feedback
       -> 204

Both are sent with credentials: 'include' and your requestHeaders (XTM One: feedback: '/chat/conversations'). message_id is the answer's persisted id — the done event's message_id, or the restored entry's id — so an answer the backend did not identify shows no thumbs.

  • A thumb is shown as given at once and rolled back, with "Could not submit feedback." (or "Could not retract feedback." for a retraction) under the message, when the backend refuses it or cannot be reached.
  • Clicking the given thumb again retracts it (DELETE).
  • Once a 👎 is stored the panel asks what went wrong: Save re-posts the rating with the comment (trimmed, at most 2000 characters), Skip keeps the rating without one.
  • A rating restored with the conversation (feedback on a restored entry) is final: only its thumb is shown, "Feedback already submitted".
  • onMessageFeedback, when passed too, is called once the backend has stored the rating. On its own it receives every rating and the panel stores nothing (no comment step, since the callback has nowhere to put one).

REST backend only, and not in singleEndpoint mode (there is no per-path routing to name a feedback route with).

Read aloud uses the browser's own speech synthesis — no endpoint, hidden where unsupported, and absent on an answer with nothing to say (code only). It reads the answer's text without its markdown or file markers, in the locale prop's language, one answer at a time; switching conversation, sending a message or leaving the panel stops it.

Customization

Custom Logo

import { MyLogo } from './icons';

<ChatPanel
  logoIcon={<MyLogo size={24} />}
  // ...
/>;

Custom Accent Color

<ChatPanel
  accentColor="#00bcd4"
  // ...
/>

Custom Prompt Suggestions

<ChatPanel
  promptSuggestions={['Help me write a report', 'Analyze this data', 'Summarize recent activity']}
  // ...
/>

Internationalization

import { useTranslation } from 'react-i18next';

function App() {
  const { t } = useTranslation();

  return (
    <ChatPanel
      t={t}
      // ...
    />
  );
}

t is only ever asked for a lookup — key in, translated string out — so any i18n library works and the package never carries a dictionary of its own. Untranslated hosts can omit it: the default is the identity function, so every key is its own English text.

Values inside sentences

A key is always a whole sentence, with {placeholder} slots for the values. The panel fills the slots in after the lookup, so a locale is free to move the value, and a translator sees the sentence rather than a fragment of one:

| Key | Renders as | | --- | --- | | 'Waiting for {count} background tasks…' | Waiting for 3 background tasks… | | 'Transferred from {agent}' | Transferred from Threat Analyst | | '{percent}% full' | 84% full | | 'How can {agent} help you, {name}?' | How can Threat Analyst help you, John? |

Two consequences worth knowing when writing the locale files:

  • Plurals are separate keys — '1 tool call' and '{count} tool calls', 'Delegating one task…' and 'Delegating {count} tasks…'. A lookup cannot select a plural form, so the panel picks the sentence and the locale translates it whole; a language with more plural forms than English can route the plural key through its own rules.
  • A missing slot is not fatal — a translation that drops a {placeholder} renders the rest of the sentence as it is, and the value is simply absent. That includes the greeting, where the agent's name is markup: a locale that rewords the sentence without {agent} gets the sentence, not a name dangling off the end of it.

promptSuggestions (and any suggestions the backend serves) also go through t, so a host may pass either keys or final text.

Translation keys used

Every key the package can ask for, grouped by where it appears:

Toggle button

  • 'Ask Assistant'

Header, agent menu and history

  • 'Agent switching is not available here'
  • 'Browse agents'
  • 'Close'
  • 'Conversation history'
  • 'Could not reach the assistant service. Check the connection and try again.'
  • 'Create agent'
  • 'Delete conversation'
  • 'Floating'
  • 'Full screen'
  • 'New chat'
  • 'New conversation'
  • 'No agent matches'
  • 'No conversations yet'
  • 'Search agents...'
  • 'Sidebar'
  • 'Switch to'
  • 'Switch to another agent'
  • 'Switch view'
  • 'Transferred from {agent}'
  • 'Untitled conversation'

Conversation sidebar

  • 'Conversation title'
  • 'Hide conversations'
  • 'No conversation matches'
  • 'Rename conversation'
  • 'Search conversations...'
  • 'Show conversations'

Welcome screen

  • 'Assistant'
  • 'Help me create a new simulation scenario'
  • 'How can I help you, {name}?'
  • 'How can {agent} help you, {name}?'
  • 'How do I configure detection rules?'
  • 'Suggestions'
  • 'Summarize my recent findings'
  • 'What are the latest attack patterns?'

Composer

  • 'A message references at most {count} conversations'
  • 'Ask a question...'
  • 'Attachments wait for the current response'
  • 'Dictate a message'
  • 'Enter to send now · Esc to stop'
  • 'Files uploading...'
  • 'Insert prompt template'
  • 'Listening...'
  • 'No conversation matches'
  • 'No prompt matches'
  • 'Reference a conversation'
  • 'Referenced conversations wait for the current response'
  • 'Search prompts...'
  • 'Send now'
  • 'Shared with you'
  • 'Stop dictation'
  • 'Stop generating'
  • 'Untitled conversation'
  • 'Uses AI. Verify results.'

Messages, markdown and files

  • 'Bad response'
  • 'Conversation transcript'
  • 'Copied'
  • 'Copied!'
  • 'Copy code'
  • 'Copy message'
  • 'Copy response'
  • 'Could not retract feedback.'
  • 'Could not submit feedback.'
  • 'Download'
  • 'Expand image'
  • 'Feedback already submitted'
  • 'Good response'
  • 'Image could not be loaded'
  • 'Image preview'
  • 'Load earlier messages'
  • 'Loading image…'
  • 'Open conversation'
  • 'Read aloud'
  • 'Reasoning details'
  • 'Reasoning details — turn limit reached'
  • 'Save'
  • 'Skip'
  • 'Stop reading'
  • 'Tell us what went wrong (optional)'
  • 'What could be improved?'

Decks, reports and videos

  • 'Download {format}'
  • 'Not rendered yet'
  • 'Open in XTM One'
  • 'PowerPoint'
  • 'PowerPoint (editable)'
  • 'Presentation'
  • 'Rendering {progress}%'
  • 'Report'
  • 'The render did not finish'
  • 'Video'
  • 'Waiting to render'
  • '{count} scenes'
  • '{count} sections'
  • '{count} slides'

Agent status

  • 'Analyzing results…'
  • 'Collecting results from one task…'
  • 'Collecting results from {count} tasks…'
  • 'Composing answer…'
  • 'Consulting {agent}…'
  • 'Delegating one task…'
  • 'Delegating {count} tasks…'
  • 'Incorporating your message…'
  • 'Thinking...'
  • 'Transferring to {agent}…'
  • 'Using tools…'
  • 'Waiting for one background task…'
  • 'Waiting for your approval…'
  • 'Waiting for {count} background tasks…'
  • 'the agent'
  • '{tool} (+{count} more)…'

Waiting panel

  • 'Almost there'
  • 'Analyzing the details'
  • 'Connecting the dots'
  • 'Consulting the sources'
  • 'Crunching the data'
  • 'Play Space Invaders'
  • 'Play Space Invaders in XTM One'
  • 'Polishing the answer'
  • 'Putting it together'
  • 'Reticulating splines'
  • 'Thinking it through'
  • 'Wrapping things up'

Reasoning details

  • '(no output)'
  • '1 tool call'
  • '1 transfer'
  • 'Input'
  • 'Model reasoning'
  • 'Output'
  • "The agent's iteration budget was exhausted - execution stopped before completing all planned steps. The final response is a best-effort summary of work done so far."
  • 'Transfer chain'
  • 'Turn limit reached.'
  • '{count} iterations'
  • '{count} tool calls'
  • '{count} transfers'

Tool approval

  • 'Also applies to your scheduled runs, until you revoke it'
  • 'Always allowed'
  • 'Approved'
  • 'Back'
  • 'Confirm'
  • 'Decline this call'
  • 'Declined'
  • 'No'
  • 'Sending…'
  • 'The agent needs your approval to run a tool:'
  • 'The agent needs your approval to run these tools:'
  • 'Why not? The agent sees this and can adapt (optional)'
  • 'Yes'
  • 'Yes, always'
  • 'e.g. wrong environment — use staging instead'
  • 'unknown tool'
  • '{decided}/{total} decided'
  • '“Yes, always” saves a preference for you. That tool will then run without asking — including on scheduled runs nobody is watching — until you revoke it.'

Context gauge

  • 'Context full — older turns are being dropped'
  • 'Context nearly full — older turns are being summarized'
  • 'Context used'
  • 'Conversation'
  • 'MCP & dynamic tools'
  • 'Summarized conversation'
  • 'System prompt'
  • 'Tool definitions'
  • 'Tool results'
  • '{counts} tokens'
  • '{percent}% full'
  • '{summary} — click for details'

Quota

  • 'Quota'
  • 'Quota reached'
  • 'Usage'
  • 'Usage · {period}'
  • 'this month'
  • 'this year'
  • 'today'
  • '{quota} (shared across all users)'

Notices, errors and timestamps

  • 'Could not send your decision. Please try again.'
  • 'No response.'
  • 'Response ready'
  • 'Sorry, an error occurred. Please try again.'
  • 'This decision could not be sent. Reload the chat and try again.'
  • 'This turn is no longer waiting for a decision.'
  • 'Unable to connect. Please check the configuration.'
  • 'Your answer is ready'
  • 'just now'
  • '{agent} has finished'
  • '{count}d ago'
  • '{count}h ago'
  • '{count}m ago'

Styling

Import the CSS file to apply default styles:

import '@filigran/chatbot/styles.css';

The component uses Tailwind CSS classes and CSS custom properties for theming. The accent color is applied via --chat-accent CSS variable.

The stylesheet is isolated from the host page in both directions. Every utility is scoped to the panel (.filigran-chatbot and its descendants) and declared outside any cascade layer, and the theme variables (--radius-*, --spacing, ...) are declared on the panel root. The host's own utilities and :root tokens - a Tailwind build shipped without layers, such as the Filigran design system's - therefore never restyle the panel, and the panel's utilities never style the host page. Panel overlays (menus, dialogs, tooltips) render inside the panel root, so they keep its styles.

Composer toolbar

Two toolbar items are data-driven rather than mode-driven: they appear only when the host serves the route, so the UI can never advertise something the backend cannot answer, and there is no mode flag to keep in step.

| Endpoint | Default path | Response | | --- | --- | --- | | Prompt library | GET {apiBaseUrl}/chat/prompts | [{ id, title, content, description? }] (or { prompts: [...] }) | | Quota status | GET {apiBaseUrl}/chat/quota | { used: number, limit: number \| null, period: string, scope?: 'user' \| 'global' } | | Agent suggestions | GET {apiBaseUrl}/chat/suggestions?agent_slug=<slug> | ["..."] (or { suggestions: [...] }, or objects with prompt/label/text) |

Set either to null in apiEndpoints to hide it. limit: null means no ceiling — the indicator then shows consumption without a bar. The quota is re-read whenever a turn finishes. The periods XTM One sets a quota for (daily, monthly, yearly) are translated (today, this month, this year), any other one is shown as sent; scope: 'global' says the limit is shared across all users.

A host behind its own proxy points the routes at it, relative to its apiBaseUrl — OpenCTI and OpenAEV pass { prompts: '/prompts', quota: '/quota', feedback: '/conversations' } (feedback: see Message feedback).

The welcome screen names the selected agent and shows its own suggestions — which is also how switching agent is confirmed: the thread resets to that screen, so without it nothing tells you who the next message will reach. When the suggestions route is unavailable the host's promptSuggestions prop is used instead, so the section is never empty.

Dictation needs no configuration at all: it uses the browser's own Web Speech API, so the mic button appears inside the text field, next to Send, wherever the API exists and is simply absent elsewhere. It is not a toolbar item, so it never adds the toolbar row on its own. While it listens the field turns red and reads Listening..., finalised phrases are appended to the composer (never replacing a draft), interim words preview in the line under it, and every send (Enter, Send, Send now) stops the mic and drops the phrase still being recognised, so no words can land in a composer the user just emptied. Stopping the mic by hand keeps that last phrase.

Context usage

The composer also carries a context gauge — a small ring plus percentage showing how full the model's context window is for the current conversation, the affordance Cursor popularised. It answers one question: is this conversation about to get shorter than I think? Long chats do not fail at the window, they get silently summarised, and a user who cannot see that coming reads the summary's gaps as the assistant forgetting.

Clicking it opens a breakdown: one stacked bar over the window plus a colour legend, so "why is this chat 84 % full" has an answer the user can act on — usually "the tool results", sometimes "the MCP tools you wired up".

Unlike the items above it needs no endpoint of its own. The backend reports the occupancy on the frames it already sends:

| Frame | Extra keys | When | | --- | --- | --- | | status: "thinking" | context_tokens, context_window, context_breakdown? | Each agent-loop iteration, so the gauge climbs during a long turn | | done | same | Closing value for the turn — a turn whose last iteration compacted ends lower than it peaked | | Restored message (POST /chat/sessions) | same | On the newest assistant message, so a reload or conversation switch restores the gauge |

context_tokens and context_window are required together and the window must be positive: a token count with no window to measure it against is not a ratio. Anything else is ignored, so a backend that reports nothing simply has no gauge — and a host on an older backend needs no configuration change.

context_breakdown is optional and validated independently, so a malformed one costs the gauge its detail but never its number. Keys, all optional, in tokens:

| Key | Legend row | | --- | --- | | system | System prompt | | tools | Tool definitions | | dynamic_tools | MCP & dynamic tools | | summary | Summarized conversation | | tool_results | Tool results | | conversation | Conversation |

Only positive values are shown, so a chat with no digest yet simply has no "Summarized conversation" row. The values must sum to context_tokens — the popover shows the lines and the headline together, and a breakdown whose parts do not add up reads as broken numbers rather than as rounding. A producer doing per-bucket integer division has to distribute the remainder rather than drop it.

Colours track the backend's own thresholds, not a design choice: neutral below 80 % (where XTM One's agent loop starts distilling older turns into a summary), amber past it, red past 95 % (where it emergency-prunes). used is a char-derived estimate of what the next call will carry — deliberately a forecast rather than a receipt for the turn that just ended, which is why the figures are prefixed with ~.

Only the rest backend carries the figures today.

Anything host-specific goes through composerToolbar:

<ChatPanel
  composerToolbar={<MySessionToolPicker />}
  {...rest}
/>

Markdown Helpers

A host that renders assistant prose with its own markdown component (its design tokens, its icon set) should still normalise the text the same way the panel does, rather than maintaining a divergent copy. These pure string → string helpers ship from the dedicated @filigran/chatbot/markdown entry point — ~2 kB, no React, no CSS. Import them from there and never from the package root, which is the full panel bundle:

import {
  hardenNestedCodeFences,
  markdownUrlTransform,
  normalizeImageMarkdown,
  normalizeMarkdownTables,
  wrapBareJson,
} from '@filigran/chatbot/markdown';

const processed = hardenNestedCodeFences(
  normalizeMarkdownTables(wrapBareJson(normalizeImageMarkdown(content))),
);

<ReactMarkdown urlTransform={markdownUrlTransform}>{processed}</ReactMarkdown>;

Order matters: alt-text is flattened before anything reads line structure, and the JSON wrap must see the raw payload before fences are hardened.

| Helper | Fixes | | ------------------------- | -------------------------------------------------------------------------------------------------------------- | | normalizeImageMarkdown | Multi-line ![alt](url) alt text, which breaks the image into literal paragraphs plus a stray link | | wrapBareJson | A whole message that is raw JSON — fenced as json so it stays readable and copyable | | `normalizeMarkdownTables` | A delimiter row whose column count doesn't match the header, including tables nested in blockquotes / list items | | `hardenNestedCodeFences` | A markdown block containing its own ``` fences, which shatters the snippet into alternating code and prose | | markdownUrlTransform | react-markdown's default sanitiser stripping data:image/* URIs (code-interpreter charts). Still blocks javascript: and non-image data: |

None of them touch content they don't apply to — an already-valid document is returned byte-identical.

Peer Dependencies

  • react >= 18
  • react-dom >= 18
  • react-markdown >= 10
  • remark-breaks >= 4
  • remark-gfm >= 4

Dependencies

  • unified ^11 and remark-parse ^11 — the ranges react-markdown 10 depends on, so a host resolves them to the copies it already has. The panel uses them to find the top-level blocks of a streaming answer, and re-renders only the blocks that can still change.

Development

To develop locally:

cd packages/filigran-chatbot
yarn dev

To build:

yarn build

To run the unit tests (Node's built-in test runner, which runs TypeScript natively from Node 22.18 / 23.6):

yarn test

To publish:

yarn publish --access public

Or from the monorepo root:

yarn publish:filigran-chatbot