iocloud-chatbot-library
v4.1.0
Published
React chat UI for the AI-EcoSystem gateway. Streams a run over SSE, renders the agent's steps, and lets the host application take part in tool calls.
Readme
iocloud-chatbot-library
React chat UI for the AI-EcoSystem gateway. Streams a run over SSE, renders the agent's steps, and lets the host application take part in tool calls.
Works with React 17 and 18. State is published through an external store read
with use-sync-external-store, so a component re-renders when the slice it reads
changes — not on every streamed token.
- One provider, anywhere in the app
- Reading state:
useChatState - Acting:
useChatActions - Reacting:
useChatEvent - Open and sidebar
- Provider props
- Placement:
ChatAppandChatContainer - Configuration
- Changing the chat on one screen:
useChatOverrides - The conversation sidebar
- Rich blocks: charts, tables and cards in an answer
- Conversation files and artifacts
- What the Gateway must serve
- Your own layout
- A fixed agent for one screen
- Meta-tool steps
- The
toolsprop - Theming
- Developing against a linked host
- Migrating from 4.0 (4.1)
- Migrating from 3.x
- Migrating from 2.x
- Migrating from 1.x
One provider, anywhere in the app
Mount ChatbotProvider once, high enough that every part of the host that wants
to drive the chat is inside it. Everything below it — the chat's own widgets and
the host's components alike — reads and acts on the same chat:
import { ChatbotProvider, ChatApp, useChatActions, useChatState } from 'iocloud-chatbot-library';
<ChatbotProvider client={client} tools={tools} config={config} open={open} onOpenChange={setOpen}>
<MyApp /> {/* any screen can drive the chat */}
<ChatApp mode="sidebar" theme="light" /> {/* draws it; portals are fine */}
</ChatbotProvider>
// Anywhere under the provider
function SummarizeButton() {
const chat = useChatActions(); // stable for the provider's lifetime
const busy = useChatState(s => s.isThinking); // re-renders only when this changes
return <button disabled={busy} onClick={() => { chat.open(); void chat.sendMessage('Summarize this page'); }}>Ask AI</button>;
}ChatApp with no provider above it creates one from its own props, which is all a
single-screen host needs. Under a provider it uses that one and ignores provider
props, with a console warning naming them.
React context follows the component tree, not the DOM, so a ChatApp rendered
through a portal still finds a provider mounted above the portal's owner.
| Hook | What it is for |
| --- | --- |
| useChatState(selector, isEqual?) | Read any slice. A component re-renders only when its slice changes. |
| useChatActions() | Do anything the chat can do. Every function keeps its identity and acts on the current state. |
| useChatEvent(type, handler) | React to a moment rather than render a state. Unsubscribes on unmount. |
| useChatOverrides(layer) | Change the configuration while one screen is mounted; withdrawn on unmount. |
| useChatConfig(), useLabels(), useIcons() | The configuration in force, with every layer applied. useLabels/useIcons also work outside a provider (they return the defaults). |
| useAuthenticatedFetch() | A stable function that fetches a storage-API URL with the session's token and returns a blob URL, or null outside a provider. |
Every hook except useLabels, useIcons and useAuthenticatedFetch throws when
there is no provider above it.
Reading state: useChatState
const sidebarOpen = useChatState(s => s.isSidebarOpen);
const { isThinking, isStopping } = useChatState(
s => ({ isThinking: s.isThinking, isStopping: s.isStopping }),
shallowEqual, // the selector builds a new object each time
);Select the smallest value you need. A selector that returns a new object on every
call re-renders on every change unless it is paired with an equality function;
shallowEqual (exported) covers the usual case.
| Field | What it holds |
| --- | --- |
| client, storageApiUrl, accessToken, userName | The session, as the provider was given it. |
| config | The configuration in force, every override layer applied. |
| isOpen, isSidebarOpen | Presence. |
| hostHandlesOpenChange, canSwitchMode, canShare, canCopyConversationLink, canStop | Whether the provider has onOpenChange / onSwitchMode / onShare / conversationLink, and whether the client can cancel a turn on the server. |
| canShowCredits, isCreditUsageOpen | Whether the client can read the user's own allowance, and whether the "Credit usage" dialog is open. |
| storageClient | The storage API's client for this user (StorageClient), built from storageApiUrl and accessToken; null without both. See Conversation files and artifacts. |
| isFilesPanelOpen, isArtifactsPanelOpen | Whether the "Conversation files" panel is open, or the "Artifacts" view is showing in place of the chat (one at a time). |
| artifactsOpenings | Counts the calls to openArtifacts() — the view goes back to its list when "Artifacts" is chosen while it already shows. |
| filesRevision | Counts the files saved and artifacts deleted through the chat — a list of files reads it to know when to read again. |
| messages | The transcript. Changes on every streamed token — read it only where you draw it. |
| isThinking, isResuming, isStopping | The turn in progress. |
| usageSummary | The consumption figures the usage ring shows. |
| pendingQuestions | The questions an ask_user_questions call is waiting on, or null. |
| staleClientCall | The open turn waits on a client tool this page can no longer answer. |
| activeConversationId | The conversation on screen, or null for a new one. |
| conversations, starredConversations | Recents (the unstarred ones when starring is supported) and Starred. |
| starringSupported | The Gateway reports starred_at, so a star will persist. |
| renamingSupported | The client can rename conversations (renameConversation). |
| isLoadingConversations, isLoadingMore, hasMore | The list's paging. |
| search | { query, results, isSearching } — the active search, if any. |
| registeredTools, enablableTools, enabledToolIds, handledToolSlugs | Tools: the host's handlers, the user-switchable tools and which are on. |
Acting: useChatActions
The object and its functions keep their identity for the provider's lifetime, so they are safe in dependency arrays and event handlers, and there is no stale closure to capture: every call acts on the current state.
| Action | Does |
| --- | --- |
| open(), close(), toggleOpen() | Opens or closes the chat. With a controlled open, asks onOpenChange instead. |
| openSidebar(), closeSidebar(), toggleSidebar() | The same for the conversation sidebar. |
| switchMode(mode) | Calls the provider's onSwitchMode. |
| share() | Calls the provider's onShare. |
| openCreditUsage(), closeCreditUsage() | The "Credit usage" dialog — see Credit usage. |
| openFiles(), closeFiles() | The "Conversation files" panel, for the open conversation. |
| openArtifacts(), closeArtifacts() | The "Artifacts" view: everything the answers produced across conversations, saved and not saved, in the chat column in place of the transcript and the composer. |
| saveFile(jobUuid, file, options?) | Stores a File or Blob in the conversation against the turn jobUuid (a block's jobUuid): an export by default, an artifact with isArtifact: true. Resolves to the stored ConversationFile; rejects with the server's reason (after 3 s of retries for a turn not stored yet), or at once without a storage API. See Saving. |
| saveArtifact(jobUuid, file, options?) | saveFile with isArtifact: true — listed under "Artifacts". A block's Save passes its blockKey prop (options.blockKey), which marks that block "Saved". |
| saveGeneratedFile(attachmentUuid) | Saves a file the application generated (an export) as an artifact: the storage API copies it. Resolves to the copy (the existing one when saved before); rejects with the server's reason (409 NOT_GENERATED for an upload or an artifact), or at once without a storage API. |
| deleteArtifact(attachmentUuid) | Deletes one of the user's artifacts; rejects when the server refuses. |
| sendMessage(text, attachedFiles?) | Sends a message in the active conversation (a new one when none is active). |
| stopTurn(), retryTurn(), continueTurn() | The turn in progress or the one that failed. |
| confirmApproval(uuid), rejectApproval(uuid) | Answers a held tool call. |
| submitQuestionnaire(formattedAnswers) | Answers an ask_user_questions call. |
| newChat() | Starts a new conversation (and leaves the Artifacts view for it). |
| selectConversation(id) | Loads a conversation (and leaves the Artifacts view for it); resolves to false if it could not. |
| deleteConversation(id) | Rejects when the server refuses, and then removes nothing. |
| setConversationStarred(id, starred) | Moves the conversation between Starred and Recents at once, and back if the server refuses. |
| renameConversation(id, title) | Shows the new title at once, and the old one again if the server refuses. A blank title changes nothing. |
| searchConversations(query) | Searches on the server; an empty query ends the search. |
| refreshConversations(), loadMoreConversations() | The list. |
| copyConversationId() | Copies the active conversation's id. |
| copyConversationLink() | Copies the provider's conversationLink for the active conversation; nothing without one. |
| setEnabledToolIds(ids) | Switches user-enablable tools on or off. Saved in localStorage (chatbot-ui:enabled-tools); ids this screen does not list are kept as they were, so a screen with fewer tools cannot switch off the others. |
| focusComposer() | Focuses the composer, unless the user is already typing somewhere. |
| setComposerText(text) | Replaces the composer's text and focuses it — to prefill a prompt from a page button. Leaves the Artifacts view, which stands where the composer is. |
Reacting: useChatEvent
useChatEvent('sidebar.opened', () => analytics.track('chat_sidebar_opened'));
useChatEvent('message.sent', ({ text }) => console.log('sent', text));The latest handler is always the one called, so it need not be memoised. A
handler that throws is reported and skipped; it cannot stop the action or the
other handlers.
| Event | Payload | Fires when |
| --- | --- | --- |
| chat.opened, chat.closed | — | isOpen changes, whoever changed it (an action or a controlled prop). |
| sidebar.opened, sidebar.closed | — | isSidebarOpen changes, the same way. |
| message.sent | { text } | sendMessage is called. |
| conversation.created | — | newChat is called. |
| conversation.selected | { conversationId } | A conversation was loaded. |
| conversation.starred | { conversationId, starred } | The server accepted a star or unstar. |
| conversation.renamed | { conversationId, title } | The server accepted a rename. |
| conversation.deleted | { conversationId } | The server accepted a delete. |
| chat.files.changed | { change: 'saved' \| 'deleted', attachmentUuid, file, conversationId, jobUuid, blockKey, savedFromUuid } | The server accepted a saveFile / saveArtifact / saveGeneratedFile (file is what it stored; blockKey names the block an artifact was saved from, savedFromUuid the generated file a copy was saved from) or a deleteArtifact (file is null). |
Use useChatState to render the current value of something, and useChatEvent to
react to the moment it happens.
Open and sidebar: own the state or let the chat keep it
Both follow the controlled/uncontrolled convention:
open/defaultOpen/onOpenChangesidebarOpen/defaultSidebarOpen/onSidebarOpenChange
Omit open and the chat keeps its own state. Pass it to own the state, for
example a host that persists it, or that must navigate when the chat closes.
onOpenChange hears every request either way, including useChatActions().close()
from anywhere in the app, so the host stays the single source of truth.
To run your own code as well as the default, listen with onSidebarOpenChange
or useChatEvent('sidebar.opened', …). To replace the default, control
sidebarOpen yourself.
Provider props
| Prop | Default | Meaning |
| --- | --- | --- |
| client | — | A GatewayStreamClient (or any StreamChatClient). |
| streamUrl, streamHeaders | — | Builds a plain StreamClient when no client is given. |
| storageApiUrl, accessToken | '', null | Attachments and authenticated images; with both, the conversation files button, the Artifacts entry and saveFile / saveArtifact. |
| userName | 'User' | Fills {name} in the greeting. |
| tools | — | See The tools prop. |
| skills | — | This app's own skills, loaded on demand. See Client skills and the host catalogue. Memoise it; omit it to leave client.setSkills alone, [] clears. |
| config | — | See Configuration. Memoise it. |
| open, defaultOpen, onOpenChange | uncontrolled, true | Presence. |
| sidebarOpen, defaultSidebarOpen, onSidebarOpenChange | uncontrolled, false | The conversation sidebar. |
| onSwitchMode | — | Enables the built-in "Switch view" entry (see Views). |
| onShare | — | Enables the header's share button. |
| conversationLink | — | (conversationId) => url: the host's URL that opens a conversation. The menu then offers "Copy conversation link" instead of "Copy conversation ID". |
| onEvent | — | Every raw stream event, for hosts that need more than the hooks. |
| allowLocalNetworkAccess | false | See LOCAL_NETWORK_ACCESS.md. |
Opening a conversation from a link. Point the client at it before the
provider first sees the client — client.setConversationId(id) — and the
provider opens it as it reopens the conversation a tab last had. A conversation
that will not load (deleted, or someone else's link) is let go of rather than
kept: the client's conversation is also where the next message goes, so keeping
it would send that message into a thread the Gateway refuses. Once the chat is
running, useChatActions().selectConversation(id) does the same and resolves to
false when it could not.
Placement: ChatApp and ChatContainer
Where and how the chat is drawn belongs to the rendering, not to the chat: the same conversation can be floating on one screen and fullscreen on another.
| Prop | ChatApp default | ChatContainer default | Meaning |
| --- | --- | --- | --- |
| mode | 'fullscreen' | 'floating' | 'floating' (corner card), 'sidebar' (panel at the edge) or 'fullscreen'. |
| allowedModes | all three | all three | The modes the built-in "Switch view" entry lists. mode falls back to the first when it is not among them. |
| theme | 'system' | 'system' | 'light', 'dark', or 'system' (follows the OS). |
| embedded | false | false | A flex child of the host's layout rather than a fixed overlay. |
| noBackground | false | false | No frame or background, for native embedding. |
| launcherIcon | config.icons.launcher | config.icons.launcher | The floating button's icon — any React node. See The floating button's icon. |
ChatContainer also takes slots: header (defaults to <ChatHeader />, null
removes it), sidebar (defaults to <ConversationSidebar />, null removes it),
footer (usually the composer) and children (the transcript, defaults to
<MainChat />; anything else must scroll itself).
A closed floating chat that is not embedded renders its launcher button, which reopens it. In fullscreen on a screen at least 960px wide the sidebar is a column beside the transcript; everywhere else it is an overlay that closes on Escape and after a pick.
The floating button's icon
The round button a closed floating chat shows is a sparkle by default. Change it on the instance, or for every instance through the configuration:
import { ChatApp } from 'iocloud-chatbot-library';
import { ReactComponent as AssistantIcon } from './assistant.svg';
// On this instance — any React node: an SVG, an icon component, a font icon, an <img>.
<ChatApp mode="floating" launcherIcon={<AssistantIcon />} />
<ChatApp mode="floating" launcherIcon={<img src="/logo.png" alt="" />} />
// For every instance under the provider, or for one screen with useChatOverrides.
<ChatbotProvider config={{ icons: { launcher: <AssistantIcon /> } }}>…</ChatbotProvider>launcherIcon wins over config.icons.launcher, which wins over the default.
The button stays a 56px circle on --cb-accent-gradient with a white
(currentColor) icon: an SVG is drawn at 24px, an <img> at 28px, a font icon
at font-size: 24px. Restyle the icon's box through .cb-launcher-icon, the
button through .cb-launcher-btn. Its accessible name is labels.openChat.
Configuration
One typed object on the provider, config: ChatbotConfig. Every group is
partial, and what a layer leaves out keeps the value beneath it. The layers stack
in this order: library defaults, then the provider's config, then each
useChatOverrides layer.
const config: ChatbotConfig = {
labels: { newChat: 'Neuer Chat', starred: 'Markiert' }, // every string; see DEFAULT_LABELS
icons: { sidebarToggle: <MyPanelIcon /> }, // every icon; see DEFAULT_ICONS
header: { close: false, switchView: false, actions: <MyHeaderButtons />, menuItems: [...] },
composer: { mic: true, onVoiceInput: startDictation, usageDetails: false },
sidebar: { items: [{ id: 'tasks', label: 'Tasks', icon: <Box />, onSelect: openTasks }] },
markdown: { blockRenderers: BLOCK_RENDERERS }, // fences the host draws; see Rich blocks
greeting: { title: 'Hi {name}, what are we doing today?' },
suggestions: [...], quickActions: [...], featureCards: [...],
};- Visibility flags only hide. A control also needs something to do, and it renders only when it has both — the tables below say what each one needs. No flag can conjure a button that does nothing.
- Lists replace rather than merge, in every layer:
quickActions: [...]is the whole set, andfeatureCards: []removes the cards. Groups (header,composer, …) merge key by key. - Memoise the object (or define it at module level). A new
configon every render re-renders everything that reads it.
header
| Key | Default | Shows when |
| --- | --- | --- |
| visible | true | false removes the whole bar. |
| brand | icon + labels.brandTitle | Any React node replaces it; false removes it. |
| actions | — | Host controls, before the built-in buttons. null in a layer removes them. |
| menuItems | [] | Extra menu entries: { id, label, icon?, onSelect, danger? }. |
| sidebarToggle | true | sidebar.enabled; hidden while the inline sidebar is open (it has its own collapse button). |
| newChat | true | The conversation has messages. |
| share | true | The provider has onShare. |
| menu | true | The menu has at least one entry. |
| switchView | true | A view other than the one in effect — see Views. |
| views, allowedViews | — | See Views. |
| creditUsage | true | The client can read the allowance (GatewayStreamClient) — see Credit usage. |
| copyConversationId | true | A conversation is active. Copies the provider's conversationLink when it has one ("Copy conversation link"), the id otherwise. |
| deleteConversation | true | A conversation is active. Takes two clicks: the first asks, in place. |
| files | true | A conversation is active, the provider has storageApiUrl and accessToken, and the Artifacts view is not showing in its place. Opens "Conversation files" — see Conversation files and artifacts. |
| close | true | The provider has onOpenChange, or the chat is floating and not embedded (its launcher can reopen it). |
Views: the "Switch view" entry
One entry at the top of the header's menu, labels.switchView with
icons.switchView. Choosing it opens the views right beneath it, inside the
menu, the one in effect ticked (icons.check); choosing another applies it and
closes the menu. A submenu beside the menu would run out of room in a narrow
floating panel, and an entry per view made the menu as long as the list. The
entry shows only while there is a view other than the one in effect, and a
divider follows it when the menu has other entries.
Built in: without
header.views, the views are the container'sallowedModes, labelledlabels.viewFloating/viewSidebar/viewFullscreenwith the icons of the same names. Choosing one calls the provider'sonSwitchMode(mode), which must exist.The host's own: a host whose placements are not the library's three passes
header.views— every view, the one in effect included — and the library lists exactly those:interface ChatViewOption { id: string; // the host's own id label: string; // the view's name: "Docked panel" icon?: React.ReactNode; active?: boolean; // the view in effect; ticked in the list onSelect: () => void; // the host applies it — or navigates, for a page }A view can be a route rather than a placement: Flowdit lists its
/aipage as "Full page", whoseonSelectnavigates there.Narrowing and hiding:
header.allowedViews: ['floating', 'sidebar']keeps only those ids (of either kind), andheader.switchView: falseremoves the entry — both usually from one screen's override layer.
Credit usage
A menu entry, labels.creditUsage with icons.creditUsage, opens a dialog over
the chat with the signed-in user's own allowance, read afresh from
GET /v1/user/credits each time it opens: the credits left, a meter of used
against granted, the date it lasts until, and one row per grant when a plan
allowance has top-ups beside it. A user with no allowance of their own sees "No
personal limit" and what their work was charged this month. When a request could
not start now, a sentence says whose limit stops it — theirs, their
organisation's, the provider account's, or no plan — and never the
organisation's or the provider's figures, which the Gateway does not send.
- Shown when
header.creditUsageis on (the default) and the client can read the allowance (canShowCredits:GatewayStreamClient'sgetCredits()). A Gateway without the route answers 404, and the dialog says the feature is not available there rather than offering a retry. - Open it from anywhere with
useChatActions().openCreditUsage();closeCreditUsage(), Escape, the close button or a click outside close it. ChatContainerrenders the dialog. A host with its own layout renders<CreditUsageDialog />once, inside a positioned element it should cover.- The figures come back as
UserCredits— read them without the dialog throughuseChatRuntime().services.fetchCredits()orConversationClient.getCredits(). - Its text is the
credits*andcreditGrant*labels.
composer
| Key | Default | Shows when |
| --- | --- | --- |
| attachments | true | The attach button; uploading needs storageApiUrl and accessToken. |
| tools | true | The tools menu, when there are user-switchable tools (those the client's agent allows, when it has one). false also skips fetching them. |
| density | 'auto' | Not a control: how much room the composer takes — see below. |
| usage | true | The consumption indicator under the composer: a ring for the context window, and the conversation's total credits beside it (until 4.0, the window's percentage, or the latest turn's credits). |
| usageDetails | true | The breakdown card the indicator opens on click. false leaves the ring and the total with nothing to open: config={{ composer: { usageDetails: false } }}. |
| disclaimer | true | "This is AI, and can make mistakes. Verify results." (labels.disclaimer). |
| mic | true | onVoiceInput is set — the library records no audio itself. |
| onVoiceInput | — | Called by the mic button. |
The usage card (usageDetails) reads the platform's per-step usage events,
so every figure is what was metered — the same numbers as the credit ledger.
What each row means, because two of them are easy to misread:
| Row | What it is |
| --- | --- |
| Context window | The size of the agent's last prompt — everything sent on its latest model call — against the model's input window (quota.models.max_input_tokens). This is the figure that grows as a conversation gets longer, and the one the ring draws. "limit unknown" and an empty ring mean the model has no window configured: set it in the admin dashboard, or the platform can neither warn about nor automatically compact a long conversation. |
| Sub-agent · name | The same for the sub-agent with the fullest prompt. Its context is its own and ends with its run. |
| This turn | Credits, and tokens in / out / steps, summed over every charged step of the turn: each model call of the agent, and each tool that runs a model of its own. So "in" is not a context size: every model call re-sends the whole conversation and is counted again, and a tool such as web research adds what its model read. 1.1M in can be eight calls of 30–60k each plus one research call that read 600k of web pages. |
| This agent / Sub-agents (n) | The turn split between the agent the user is talking to and the sub-agents it started; shown only when it started any. |
| Whole conversation | Credits over every turn of the thread. |
Credits are charged per step and rounded up per step, a tool's multiplied by its weight first: a model call worth 0.2 of a credit costs 1. "Steps" counts charged steps only — meta-tool calls, refusals and the sub-agent hand-off are free and not counted.
Density. 'comfortable' is the roomy box: the text above a row of buttons.
'compact' is a single 48px row — attach and tools on the left, mic and send on
the right, the text between them — with the disclaimer and the usage ring on the
footer line beneath. 'auto', the default, is comfortable while the
conversation is empty, where the composer is the page, and compact once it has
messages, where the height belongs to the transcript.
The input grows with its text up to 180px and keeps one row while empty: it is never sized by its placeholder, and it re-fits when its width changes. A compact composer gives the text the full width, buttons on a row beneath, once the text needs a second line, and keeps that layout until the box is emptied — switching back as soon as the text fit again would re-wrap it at the narrower width and flip straight back.
// A screen with little height to spare: compact even before the first message.
useChatOverrides({ composer: { density: 'compact' } });The questionnaire an ask_user_questions call raises sits above the
composer, the same width as its box. Choices are chips that wrap, so short
answers share a row; a single question has no pager; radios move with the arrow
keys; and Enter in its text fields answers — Shift+Enter is a new line — the way
it sends from the composer. While it waits, the composer stays, disabled, because
it holds the Stop button for the run that asked.
sidebar
| Key | Default | Meaning |
| --- | --- | --- |
| enabled | true | false removes the sidebar and its toggle. |
| newChat | true | The "New chat" entry. |
| artifacts | true | The "Artifacts" entry under "New chat"; shown only while the provider has storageApiUrl and accessToken. See Conversation files and artifacts. |
| items | [] | Navigation entries under "Artifacts": { id, label, icon?, onSelect, active?, badge? } — a Tasks page, say. None by default. |
| starred | true | The Starred section; shown only while the Gateway reports starred_at. |
| search | true | The search box. |
| rename | true | "Rename" in a row's menu; needs a client with renameConversation. |
| deleteConversation | true | "Delete" in a row's menu; two clicks. |
Welcome content
What an empty conversation shows. None of it has defaults: 3.0 ships no prompts, cards or privacy claims, because they describe one host's product. Choosing a prompt sends it.
| Key | Where | Shape |
| --- | --- | --- |
| greeting | Every mode | { title?, privacyBadges? }. {name} in the title is the provider's userName; the title defaults to labels.greetingTitle. A badge is { text, icon? }, icon lock / shield / check. |
| suggestions | Compact (floating, sidebar) | { id, text, prompt?, icon? }, icon pin / clipboard / message or any React node. |
| quickActions | Fullscreen | Pills: { id, label, prompt?, icon?, prompts? }, icon clock / pencil / bolt / sparkle / clipboard / message / help or any React node. |
| featureCards | Fullscreen, under the pills | { id, title, description, badge?, icon?, image?, prompt? }. icon (globe, file, settings, shield, chart, slides) also picks the card's illustration unless image is set. |
A quick action with prompts is a category. Its pill opens a list of those
prompts between the pills and the feature cards, which stay visible below. The
pill again, or Escape, closes it.
quickActions: [{
id: 'analyze', label: 'Analyze', icon: 'bolt',
prompts: [
{ id: 'gaps', label: 'Show me compliance gaps across my sites' },
{ id: 'template', label: 'Show me how a template is performing', behavior: 'insert' },
],
}]| Prompt key | Meaning |
| --- | --- |
| id, label | The row. |
| prompt | What is sent or inserted; the label when omitted. |
| behavior | 'send' (default) sends it. 'insert' puts it in the composer for the user to finish — for a prompt that needs a name, a date or a site first. |
Labels
Every string the chat shows is a key of ChatLabels, with its English default in
DEFAULT_LABELS. Pass a partial map; keys you leave out keep the default.
- Placeholders are
{name}style (uploadingFiles: 'Uploading {count} file(s)…'), so a label can come straight from a translation file. Fill your own withformatLabel(template, values). - Counts take one/other pairs —
creditsOne/creditsOther,stepsOne/stepsOther,turnsOne/turnsOther— filled withformatCount(count, one, other). - A host that translates through its own message catalogue can map every key at
once:
Object.keys(DEFAULT_LABELS)is the full list, solabels[key] = messages[chat.${key}] ?? DEFAULT_LABELS[key]translates whatever the catalogue has and leaves the rest in English. Read the raw message rather than formatting it: the library fills its own placeholders, and a formatter such as IntlMessageFormat throws on a placeholder it was given no value for.
The keys, by area: header (brandTitle, openSidebar, closeSidebar, newChat,
newChatTitle, share, moreOptions, switchView, viewFloating,
viewSidebar, viewFullscreen, creditUsage, copyConversationId, copiedConversationId,
copyConversationLink, copiedConversationLink, deleteThread, confirmDeleteThread,
closeChat, openChat); sidebar (sidebarTitle, collapseSidebar,
searchPlaceholder, starred, starredEmpty, recents, searchResults,
noSearchResults, star, unstar, conversationActions, renameConversation,
conversationTitle, deleteConversation, confirmDeleteConversation, untitledConversation,
loadMore, loading, noHistoryTitle, noHistoryBody, today, yesterday,
previous7Days, older); transcript (scrollToLatest, staleClientCall,
greetingTitle); composer (composerPlaceholder, questionnairePlaceholder,
dropFiles, addAttachment, attachmentsUnavailable, removeAttachment,
uploadingFiles, unsupportedFileType, fileTooLarge, uploadNeedsStorageUrl,
uploadNeedsToken, uploadFailed, tools, voiceInput, send,
waitingForUploads, stop, stopping, disclaimer); messages
(assistantName, copy, copied, reply, edit, retry, continue,
thinking, thoughtBriefly, show, hide, toolCalling, toolUsed,
toolRunning, toolFailed, toolRan, openResult, openPreview,
toolArguments, toolResult, searchResultsHeading, previousVersion,
nextVersion, confirm, cancel, executingTool, confirmedExecuting,
cancelled); meta-tool steps (metaSearchingTools, metaSearchedTools,
metaLoadingTools, metaLoadedTools, metaSearchingSkills,
metaSearchedSkills, metaLoadingSkill, metaLoadedSkill, metaRefused —
see Meta-tool steps); the activity section
(activityThoughtOne/Other, activitySearchedOne/Other,
activityLoadedSkillOne/Other, activityLoadedToolsOne/Other,
activityRanOne, activityRanRepeated, activityRanOther,
activityAgentOne/Other, activityAgentWorking, activityFailed,
activitySeparator — see The activity section);
rich blocks (blockPreparing, blockFailed, blockShowSource, blockExpand,
blockMore, blockCopySource, blockClose — see Rich blocks);
attachment viewer (close, closeAttachment, attachmentLoadFailed,
attachmentNoAuth, pdfLoadFailed, downloadFile, downloadFailed,
previewTruncated); conversation files and artifacts (conversationFiles,
conversationFilesEmpty, filesLoadFailed, fileArtifactBadge,
fileGeneratedBadge, previewFile, artifacts, artifactsEmpty,
artifactsLoadFailed, searchArtifacts, noArtifactResults,
noArtifactFilterResults, artifactKindFilter, artifactKindAll,
artifactKindChart, artifactKindTable, artifactKindKpi, artifactKindReport,
artifactKindFile, openConversation, backToArtifacts, deleteArtifact,
confirmDeleteArtifact, artifactDeleteFailed); saved and not saved (savedBadge,
notSavedBadge, saveFile, saveFileFailed, fileSavedAnnouncement, showInChat, showInChatUnavailable,
blockUntitled, blockNoRenderer, blockLoadFailed, filesFilter, filesFilterAll,
filesFilterVisuals, filesFilterFiles, conversationVisualsEmpty, artifactStatus,
artifactStatusAll, artifactStatusSaved, artifactStatusUnsaved, artifactsSavedEmpty,
artifactsUnsavedEmpty); the files panel's look (conversationFilesEmptyTitle,
conversationFilesEmptyHint, filesItemsOne, filesItemsOther, filesSavedCount,
itemKindChart, itemKindTable, itemKindKpi, itemKindReport, itemKindFile,
itemKindImage, itemKindPdf, itemKindSpreadsheet); questionnaire (answerPlaceholder,
otherOption, customAnswerPlaceholder, previousQuestion, nextQuestion,
submit, submitted); usage (usageTitle, usageFootnote, usageUpdating, usageTrigger,
usageSummaryWithWindow, usageSummary, usageContextWindow, usageLimitUnknown,
usageLastPromptNote, usageSubAgentContext, usageLargestPrompt,
usageOwnContextNote, usageNearLimit, usageThisTurn, usageThisAgent,
usageSubAgents, usageWholeConversation, usageFreeCalls, usageWaived,
usageSubAgentTree, usageTreeLimitReached, usageTokens, creditsOne,
creditsOther, stepsOne, stepsOther, turnsOne, turnsOther); credit usage
(creditsLeftOne, creditsLeftOther, creditsUsedOfCap, creditGrantPlan,
creditGrantTopUp, creditGrantLeft, creditsValidUntil, creditsNoLimitTitle,
creditsNoLimitBody, creditsUsedThisMonth, creditsBlockedUser,
creditsBlockedOrganization, creditsBlockedProvider, creditsBlockedNoPlan,
creditsLoadFailed, creditsUnavailable).
Icons
Every built-in icon is a key of ChatIcons, with its default in DEFAULT_ICONS.
Values are plain React nodes — a font icon, an <img> or another icon library's
component — and the defaults are drawn with currentColor, so they follow the
theme.
brand, launcher (also the launcherIcon prop — see The floating button's icon), sidebarToggle, sidebarCollapse, newChat, search,
star, starFilled, sectionCollapsed, sectionExpanded, promptRow, delete,
rename, more, share, viewFloating, viewSidebar, viewFullscreen, switchView,
check, creditUsage, copy, link, close, attach, tools, mic, send, stop,
scrollDown, files, artifacts, download, expand, openConversation, back
(an opened artifact's "Back to artifacts", drawn pointing left and mirrored
right-to-left), and one per artifact kind: artifactChart, artifactTable, artifactKpi, artifactReport,
artifactFile; one per plain file's type, in the files lists' rows: fileImage, filePdf,
fileSpreadsheet (anything else is artifactFile); and for saved and not saved: saved (the "Saved" mark), notSaved (the
"Not saved" mark), save (a generated file's Save) and showInChat (a block's "Show in chat"
in the files panel).
sidebarToggle and sidebarCollapse are one panel drawn twice, its chevron
reversed: the header shows the first while the sidebar is closed and the second
while it is open, and the sidebar's own collapse button shows the second.
Replace them as a pair.
Changing the chat on one screen: useChatOverrides
A screen lays its own configuration over the provider's for as long as it is mounted, and the layer is withdrawn when it unmounts — no cleanup code, no state left behind for the next screen:
| On this screen… | Layer |
| --- | --- |
| no close button | { header: { close: false } } |
| no "Switch view" entry | { header: { switchView: false } } |
| only some views | { header: { allowedViews: ['floating', 'sidebar'] } } |
| no header controls of the host's | { header: { actions: null } } |
| different quick-action pills, or categories | { quickActions: [...] } |
| no quick-action pills | { quickActions: [] } |
| different feature cards | { featureCards: [...] } |
| no feature cards | { featureCards: [] } |
| different compact suggestions | { suggestions: [...] } |
| another composer placeholder | { labels: { composerPlaceholder: '…' } } |
| no sidebar | { sidebar: { enabled: false } } |
| no microphone, attachments or tools menu | { composer: { mic: false, attachments: false, tools: false } } |
// A module constant: the same object on every render.
const AUDIT_SCREEN_CHAT: ChatbotConfig = {
header: { close: false, allowedViews: ['floating'] },
featureCards: [],
};
function AuditScreen() {
useChatOverrides(AUDIT_SCREEN_CHAT);
// Translated content is built once with useMemo.
const prompts = useMemo<ChatbotConfig>(() => ({
quickActions: [{
id: 'audit', label: t('audit.ai.category'), icon: 'bolt',
prompts: [{ id: 'gaps', label: t('audit.ai.gaps') }],
}],
}), []);
useChatOverrides(prompts);
…
}- Layers apply in the order they were first registered, over the provider's
config. - Pass a stable object — a module constant or a
useMemoresult. A layer equal to the one already registered is ignored, which keeps a literal of plain values cheap, but a literal holding elements or functions is new on every render and re-registers every render. - A screen with a provider of its own (a separate chat) configures that provider directly; overrides affect the nearest provider above the caller.
The conversation sidebar
New chat, then Artifacts (with a storage API — see Conversation files and
artifacts), then the host's sidebar.items,
then Starred and Recents, with search over all of them. Selecting from an overlay closes it; the
fullscreen column on a wide screen stays open.
- Starred appears only when the Gateway reports
starred_aton its conversations (revision 009,PATCH /v1/user/conversations/{id}). On an older Gateway, starring could not persist, so it is not offered. A star moves the row at once and moves it back if the server refuses. - Search asks the Gateway (
q), which matches the stored title and anything the user wrote in the conversation — never a prompt one agent wrote for another. It is debounced (250ms). On a Gateway that predates search, the results are narrowed to matching titles on the client instead. - Recents are grouped Today / Yesterday / Previous 7 days / Older and paged 20 at a time.
- Each row's "more" menu holds Star, Rename and Delete — one button rather than three, which left a title a few characters in a 260px column.
- Rename edits the title in place: Enter or leaving the field saves, Escape cancels. The row shows the new title at once and the old one again if the server refuses. A title the user chose is never overwritten by a generated one.
- Titles are generated by the Gateway from a thread's first turn, after that turn ends, so the list is read again a few seconds after a new thread's first reply (3s and 8s) to pick the title up.
- Delete takes two clicks — the first turns the entry into the question — and
the row is removed only after the server accepts. The Gateway hides a deleted
conversation and removes nothing (revision 011). Until 4.0 delete was off by
default, because the Gateway served no
DELETEroute.
Rich blocks: charts, tables and cards in an answer
New in 4.1 (2026-10-14). A host draws fenced code blocks itself. The model
writes a fence in its answer — ```chart, ```table, whatever languages the
host registers — and the chat draws the host's component in its place: inline,
next to the sentence that refers to it, several per answer. The library knows no
chart; it owns the frame, the streaming, the failure path and the plumbing.
const BLOCK_RENDERERS: Record<string, BlockRenderer> = { // module level: its identity is compared
chart: { component: ChartBlock, label: 'chart', minHeight: 260 },
table: { component: TableBlock, label: 'table' },
};
<ChatbotProvider config={{ markdown: { blockRenderers: BLOCK_RENDERERS } }} … />| BlockRenderer | Default | Meaning |
| --- | --- | --- |
| component | — | A component taking BlockRenderProps. Rendered only once the fence is whole. A renderer without one React can render is ignored with a console warning. |
| placeholder | the library's skeleton | Shown while the fence streams: { language, partialSource, label }. |
| toText(source) | [<label>: <title>] | What the block reads as in "Copy". |
| minHeight | 160 | Height (px) the skeleton reserves, so the transcript does not jump. |
| label | the language | The word the skeleton ("Preparing chart…") and the fallback use. |
| frame | true | false draws the component bare: no border, title, toolbar or menu. |
config.markdown.onExpandBlock(block) takes "Expand" over — return true to
show the block somewhere of your own (a side panel); otherwise the library's
overlay opens. A layer's blockRenderers: { chart: null } removes a renderer set
beneath it, for a screen that must not draw charts.
The resolved map (useMarkdownConfig().blockRenderers) has no prototype: a
fence's language is text from the answer, and ```constructor must not find
Object.prototype.constructor. Read it by key or with Object.keys, or look a
language up with rendererFor(map, language) — which also guards a map of your
own — but not with map.hasOwnProperty(…), which it does not have.
What a block's component is given (BlockRenderProps):
| Prop | Meaning |
| --- | --- |
| language, source, meta, blockKey, messageId | The fence: its language, body and info string after the language; blockKey is fnv1a(language + '\n' + source), the same live and after a reload. |
| jobUuid, conversationId | The turn that wrote the answer and its conversation — what a block is saved against (saveArtifact(jobUuid, …)). jobUuid is null until a live turn's first frame names its job. |
| streaming | The turn is still in flight: a reference to one of its steps may not have a result yet. Wait rather than fail. |
| expanded | Drawn in the expanded overlay. |
| width | The width the component can draw in, in px: the body's content box (inside its padding), the same box on the first measurement as on every later one. Kept current; 0 until measured. |
| theme, direction, mode | 'light'/'dark' as drawn (system resolved), the text direction around the block, the chat's placement. |
| getMessageSource() | The whole answer's text, for a block that exports its siblings (a report). |
| setTitle(title) | The title in the frame's header. |
| setActions(actions) | The block's actions: { id, label, icon?, run({ element, messageElement }), placement?: 'menu' \| 'toolbar', disabled? }. Menu entries sit under "More actions" with "Copy source"; at most one or two toolbar buttons beside Expand. Calling it again with the same ids, labels, placements and states changes nothing, so it is safe in an effect; a click runs the latest run. |
| openExpanded() | Opens the overlay (or onExpandBlock). |
| fail(reason) | Shows the library's fallback: "Couldn't display this chart: reason", with the source. |
How it behaves:
- Streaming. A fence is drawn as a skeleton — with the title once
"title"has streamed — until its closing line arrives; nothing is parsed from a partial body. A fence the answer ended inside counts as closed. Once drawn, a block is not rendered again by later tokens: thecomponentsmap is a module constant, the block is memoised on its props, andgetMessageSourceis a stable reader. Memoiseconfigand the renderer map, or every change of them re-renders every block. - Failure. A component that throws is caught by an error boundary around
each block; the answer around it stays, and the fallback shows the reason and
the source. A
componentReact cannot render at all is caught there too. - Whose messages. Only the assistant's answers draw blocks. A user's own
message shows a fence they pasted as code, and its "Copy" gives back exactly
what they wrote.
MarkdownContent'sblocks={false}does the same for a host. - Expanded. A modal overlay portalled to
document.body, carrying.cb-rootand the chat'sdata-themeso the tokens apply there too. Escape and the scrim close it; Tab stays inside (a popup your component portals out of it keeps its own Tab order); focus returns to Expand. Its header is the title, thetoolbaractions, "More actions", then the close button — last. - Copy. The message's "Copy" replaces each claimed fence with
renderer.toText(source)or[<label>: <title>](toPlainText). - Line endings. A block's
sourcehas\nline endings whatever the answer used, so itsblockKeyis the oneextractBlocksgives for it. - A fence the answer ended inside is drawn (once the text stops) with its
body as the parser hands it over: without the text's final line break.
extractBlocksreads it the same way since 2026-10-14, so itsblockKey— the one a Save sends and the platform indexes — is the drawn block's. - Found again. A drawn block's frame carries
data-block-keyanddata-job-uuid(its turn), which the files panel's "Show in chat" goes to. - Indexed. Every job names the languages the page draws
(
block_languages, from the mergedblockRenderers), so the platform indexes those blocks when the turn ends: a block nobody saved is listed as "Not saved" in the conversation's files and under Artifacts — see Saved and not saved. - Width. An answer takes its row's whole width since 4.1 (a user message keeps 80%), so a block gets ~330px in the 400px floating panel.
- Styles. A block's body sits inside the message's
.cb-markdown-content, whose rules (p,table,code…) reach a renderer's elements: scope a renderer's styles with a class of its own. The frame isfigure.cb-md-block(.cb-md-block-header,-title,-toolbar,-body,-fallback), tokens only. - Without renderers — another host, an old answer — a fence stays a code block, so the JSON is still readable.
Exports for a host that renders or exports answers itself: MarkdownContent
(the one markdown renderer every answer goes through; blocks={false} keeps
every fence as code), toPlainText(markdown, renderers), extractBlocks(markdown,
languages, messageId?) (every block of those languages, with the same
blockKey), scanBlocks(markdown, languages, messageId?) (the same, with
whether each fence has closed), extractBlockTitle, isFenceClosed, fnv1a,
blockKeyOf, blockLanguagesOf(languages) (what a job sends as
block_languages), rendererFor(renderers, language), and useMarkdownConfig().
A block drawn outside its message — in a panel of your own, say — is
<ExpandedBlock language source renderer jobUuid conversationId meta? messageId?
getMessageSource? streaming? onClose? />: the host renderer, expanded and not
streaming (pass streaming while the block's turn is still under way, and a
getMessageSource that reads the message's current text), in the library's
frame, so its own Save saves it against jobUuid (pass the turn's). <BlockDialog label onClose returnFocusTo?> is the library's expanded
overlay around it — a modal dialog over the page, as the files panel opens a
block in.
Conversation files and artifacts
New in 4.1 (2026-10-14). With storageApiUrl and accessToken on the
provider, the chat shows what a conversation has stored, and what the user chose
to keep:
- The header's files button (
header.files) opens "Conversation files": everything of the open conversation, newest first — what the user attached, what the application generated (an export), what the user saved, and every chart, table, KPI card or report its answers drew — with filter chips (All · Visuals · Files) and a Saved / Not saved mark (an icon and the word) on every artifact; the user's uploads carry none. A file opens in the viewer (images, PDFs, a CSV as a table, text; anything else as a download) and downloads with the user's token; a generated file nobody saved has a Save button. A drawn block opens drawn live by its renderer in the expanded overlay — its own Save saves it — and "Show in chat" closes the panel and goes to it in the transcript. See Saved and not saved. It is a dialog sized to its list (2026-10-14): centred and rounded, as tall as its content up tomin(680px, 80vh)with the list scrolling under a fixed header and filter — a sheet at the bottom of the chat, with small margins, in a narrow one (the floating card, a docked panel). Its header says the conversation and "6 items · 2 saved"; the filter is a segmented control with a count per option; under "All" the list is two sections, Visuals then Files, newest first in each. A row is a tile tinted by its kind (chart, table, KPI card, report, image, PDF, spreadsheet, file — or the image itself), its title, its mark as a small pill, and its details (kind, when — relative within the week in whole units, the full date as the tooltip — and size); its actions show on hover or focus with a mouse, and always on a touch screen or in a narrow chat. In a narrow chat the pill is its icon (a filled or a dashed bookmark; the word stays in the row's name) and the details wrap between parts to a second line, so the date and the size always show. Loading shows four rows' skeletons; an empty conversation, a heading ("No files yet") and what will appear there; an empty filter, its own line; a failed read, a message with Retry. - The sidebar's "Artifacts" entry (
sidebar.artifacts), under "New chat", opens "Artifacts": everything the answers produced, across conversations, saved and not saved — a segmented control (All · Saved · Not saved) above the kind filter (charts, tables, KPIs, reports, files), a mark on every row — with search, 20 at a time, each with its conversation's title and a button that opens it, download (a stored file), and delete (an artifact; two clicks). "Load more", a search and a save keep the pages already loaded: a read again reads everything on screen, not just page one, in pages of at most 100. A "Load more" under way when the list is read again is neither lost nor shown twice, even when that read fails. - "Artifacts" is a view in place of the chat, not a dialog over it. It
takes the chat column's place — where the transcript and the composer are —
under the header, with the sidebar where it was (inline in fullscreen; a
drawer elsewhere, which closes as it does for a conversation). The entry is
marked current (
aria-current="page") while it shows, and no conversation is. The transcript and the composer stay mounted behind it, hidden from the eye and from assistive technology, so a draft, an upload under way and the scroll position are there to come back to. The view is a labelled region: no scrim, no focus trap. Opening it puts focus in its search; choosing "Artifacts" again while it shows goes back to the list (from an open artifact) with focus in the search. The header's files button hides while it shows: the conversation it would describe is not on screen. - An artifact opens in full in that same column. Its header has "Back to artifacts" at the start, its kind and title, its Saved / Not saved mark, its actions (Save, download, open its conversation, delete) and the close button last; below, its conversation, date and size, then the file — an image as wide as the column (scrolling when taller), a PDF a page at a time at the column's width, a CSV as a table, text as text, anything else as a card with a download — in the chat's own theme. A file that cannot be fetched says so in place, and the artifact stays open. A block nobody saved is drawn live there, full width, by the host's renderer; a generated file nobody saved shows as its file with Save. "Back" returns to the list as it was left: search, filters, the pages loaded, the scroll position, and focus on the row (or on the artifact it was saved as).
- Leaving the view. Its close button, "Open conversation", a conversation
picked in the sidebar, "New chat" (sidebar or header) and
setComposerTextgive the column back to the chat. The conversation that was open behind the view, picked again, comes back as it was — not loaded again, so an answer still streaming goes on. Escape steps back — from an artifact to the list, from the list to the chat — when pressed in the chat (the view, or an inline sidebar beside it), not from the host's page around it. While another dialog (credit usage), an overlay sidebar or the header's menu is open, the Escape is theirs, wherever focus is. Focus goes back to the "Artifacts" entry when it is on screen, else to the composer; after "Open conversation", to the composer; and nowhere when a dialog took the column (openFiles()), which keeps it. - The files panel is a modal dialog over the chat column. Tab stays inside it. Escape closes it from anywhere in the chat (the panel, or the sidebar beside it), but not from the host's page around the chat; with nothing focused — the row the user was on was deleted — it goes by where the user last clicked or focused. A file opens in the viewer (a lightbox over the page); closing it puts focus back on its row.
- A header's close button is last — in the files panel, the Artifacts view and an opened artifact, the attachment viewer and an expanded block: after every action and the "More actions" menu. The row follows the page's direction, so right-to-left it is the left end.
- Thumbnails. An image of up to 1 MB shows itself in its row, fetched only once the row comes near the visible list and three at a time; a larger one shows its kind's icon (the storage API has no smaller rendition). The provider keeps fetched files' object URLs in a bounded cache (60 files or 48 MB, least recently used out first) and lets a deleted artifact's go at once.
Saving a file or an artifact
A file is stored against a turn: saveFile(jobUuid, file, options) — the
block's own jobUuid. The storage API checks that the turn is the user's.
const ChartBlock: React.FC<BlockRenderProps> = ({ jobUuid, source, setActions }) => {
const { saveArtifact } = useChatActions();
const [saved, setSaved] = React.useState(false);
const { title } = JSON.parse(source);
React.useEffect(() => setActions([
{ id: 'png', label: 'Download PNG', run: ({ element }) => downloadPng(element) },
{
id: 'save', label: saved ? 'Saved' : 'Save', placement: 'toolbar', icon: <SaveIcon />,
disabled: saved || !jobUuid,
run: async ({ element }) => {
await saveArtifact(jobUuid!, await toPngBlob(element), { title, kind: 'chart', spec: source });
setSaved(true);
},
},
]));
…
};| SaveFileOptions | Meaning |
| --- | --- |
| isArtifact | true: an artifact, listed under "Artifacts" too. false (the default): a generated file of the conversation — an export. saveArtifact sets it. |
| title | Shown instead of the file name (one line, 255 characters). |
| kind | 'chart', 'table', 'kpi', 'report' or 'file'. |
| spec | What the file was drawn from (the block's body), kept so it can be drawn again. The storage API keeps one JSON object of at most 256 KiB, and refuses the whole save otherwise, so: an object, or a string holding one, is sent as that object; anything else — JSON your renderer had to repair, an array — is kept as its text under source ({"source": "…"}; hand spec.source back to the renderer to draw it again); a spec still over the limits (size, 64 levels of nesting, a NUL character) is left out with a console warning and the file is saved without it. prepareSpec(spec) says beforehand what will be sent. |
| filename | The name for a bare Blob; otherwise the title plus the type's extension. |
| blockKey | For an artifact saved from a drawn block: the block's blockKey prop, sent as block_key. It marks the block "Saved" in the conversation's files and takes it off "Not saved" under Artifacts. Sent only with isArtifact: true; a key the storage API would refuse is left out with a console warning. |
The promise resolves to the stored ConversationFile (attachment_uuid,
job_uuid, conversation_uuid, conversation_title, filename,
content_type, size_bytes, is_artifact, artifact_kind, title,
has_spec, source, created_at, download_url, and since 2026-10-14
saved, block_key, saved_from_uuid) and rejects with the
server's reason (StorageRequestError: status, code, message) — a type it
does not store (SVG and HTML are refused), more than 10 MB, a turn that is not the
user's. After it resolves the panels read again and chat.files.changed fires.
A block's jobUuid is set as soon as the stream names the turn, a moment before
the platform has written the turn's row, so a Save pressed at once can find the
storage API not knowing the turn yet (404 JOB_NOT_FOUND). saveFile tries
that answer again three times, 0.5, 1 and 1.5 s apart, before rejecting with
it; every other refusal rejects at once. A turn that really is not the user's
is therefore refused about 3 s later.
useStorageClient() (or new StorageClient({ storageApiUrl, token })) reads
directly: listConversationFiles(conversationId) → { conversation_uuid,
files, blocks } (the files newest first; the blocks the platform indexed,
BlockRecord[], oldest turn first — dedupe them by (turn_job_uuid,
block_key); until 2026-10-14 it resolved to the files alone),
listArtifactEntries({ status, kind, q, limit, offset }) → { items:
ArtifactEntry[], total, limit, offset, hasMore } (saved and not saved;
status all | saved | unsaved), getBlock(uuid) (a block with its
source, to draw it again), saveGeneratedFile(uuid), listArtifacts({
limit, offset, q, kind }) → { items, total, limit, offset, hasMore } (saved
artifacts only) — every list query brought within what the storage API takes:
limit 1–100, offset 0–1,000,000 (MAX_ARTIFACT_OFFSET; a page past it is
empty, without a request), q without NUL characters and cut at 200
characters (it answers anything else with a 422) — getArtifact(uuid) (with its spec), deleteArtifact(uuid),
fetchFile(file) → Blob, downloadFile(file), fileDownloadUrl(jobUuid,
attachmentUuid). normalizeConversationFile, normalizeBlockRecord and
normalizeArtifactEntry read the storage API's records for a host that calls
a route of its own.
Saved and not saved
2026-10-14. An artifact is anything the agent produced for the user: every block it drew (a fence of a language with a renderer) and every file a tool generated. Saved — the user pressed Save, and a stored, downloadable artifact exists; Not saved — drawn or generated, never saved. The user's own uploads are neither.
- Indexing. Every job carries
block_languages— the languages of the mergedconfig.markdown.blockRenderersthe platform takes (^[a-z][a-z0-9_-]{0,31}$, each once, sorted, at most 16; left out with none). When the turn ends, the platform indexes those fences of its answer, sub-agents' included.GatewayStreamClient.setBlockLanguages(languages)andgetBlockLanguages()(the provider calls the first; a host rarely needs them), orblockLanguagesin its config. - The link. A block is known by
(turn job, blockKey). A block's Save passesblockKeytosaveArtifactagainst the block'sjobUuid(the turn's), and the block is then Saved. Deleting the artifact makes it Not saved again. - The files panel lists the server's blocks together with those in the
loaded transcript — the turn under way (a fence still being written is
left out) and older conversations the platform never indexed show at once —
one row per turn and key. A saved block's artifact, and a generated file's
saved copy, are folded into that block's or file's row. It reads again on
chat.files.changedand twice shortly after a turn ends (1.5 s and 5 s), when the platform has indexed it. A block of the turn under way opens withstreaming: trueand agetMessageSourcethat reads its message as it grows, until the turn ends. A reloaded handoff child's answer belongs to the turn that handed over (MessageProps.jobUuid, as live), so its blocks are one row with the platform's. Picking another conversation (the inline sidebar stays usable beside the panel) shows that one's list, never the previous one's. A block of a language with no renderer on this page is a row with its language that opens nothing; "Show in chat" is off, with its reason, for a block whose answer is not in the transcript on screen. - Artifacts reads
GET artifact-entries: a saved block is listed as its artifact file; a not-saved block is fetched (getBlock) and drawn live when opened —expanded: true,streaming: false,jobUuidthe block's turn,conversationIdits conversation (not the one open),blockKeyits key — so a renderer that resolves references reads that conversation. - Renderers. Drawn outside its message, a block is given
messageId''(or its message's, from the panel) and agetMessageSourceof its answer when the transcript holds it, else of its own body. Its Save works as in the chat; show "Saved" fromchat.files.changed(blockKey,jobUuid). - Known divergences. The transcript's extraction (
scanBlocks,extractBlocks) and the platform's index — a port of it — read two shapes otherwise than the renderer draws them: an unclosed fence in a list item followed by less-indented text (extracted to the end of the text, drawn to the end of the item: the drawn block's key is not the indexed one, so it stays Not saved in the lists after a save) and a fence inside an HTML block (d
