@filigran/chatbot
v3.14.0
Published
Filigran chat panel — React + Tailwind standalone chatbot component
Keywords
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/chatbotQuick 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 chainis_truncated—truewhen 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}/downloadwith 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 processingtool_start— Agent is using tools (withtoolsarray)tool_heartbeat— Liveness signal during a long tool execution (withtoolsandelapsed_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 stuckanalyzing— Agent is analyzing tool resultscomposing— Agent is composing the responsestreaming— Content is being streamedsteering— 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
-> 204Both 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 (
feedbackon 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 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>= 18react-dom>= 18react-markdown>= 10remark-breaks>= 4remark-gfm>= 4
Dependencies
unified^11 andremark-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 devTo build:
yarn buildTo run the unit tests (Node's built-in test runner, which runs TypeScript natively from Node 22.18 / 23.6):
yarn testTo publish:
yarn publish --access publicOr from the monorepo root:
yarn publish:filigran-chatbot