npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 / onOpenChange
  • sidebarOpen / 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, and featureCards: [] removes the cards. Groups (header, composer, …) merge key by key.
  • Memoise the object (or define it at module level). A new config on 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's allowedModes, labelled labels.viewFloating / viewSidebar / viewFullscreen with the icons of the same names. Choosing one calls the provider's onSwitchMode(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 /ai page as "Full page", whose onSelect navigates there.

  • Narrowing and hiding: header.allowedViews: ['floating', 'sidebar'] keeps only those ids (of either kind), and header.switchView: false removes 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.creditUsage is on (the default) and the client can read the allowance (canShowCredits: GatewayStreamClient's getCredits()). 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.
  • ChatContainer renders 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 through useChatRuntime().services.fetchCredits() or ConversationClient.getCredits().
  • Its text is the credits* and creditGrant* 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 with formatLabel(template, values).
  • Counts take one/other pairs — creditsOne / creditsOther, stepsOne / stepsOther, turnsOne / turnsOther — filled with formatCount(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, so labels[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 useMemo result. 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_at on 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 DELETE route.

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: the components map is a module constant, the block is memoised on its props, and getMessageSource is a stable reader. Memoise config and 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 component React 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's blocks={false} does the same for a host.
  • Expanded. A modal overlay portalled to document.body, carrying .cb-root and the chat's data-theme so 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, the toolbar actions, "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 source has \n line endings whatever the answer used, so its blockKey is the one extractBlocks gives 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. extractBlocks reads it the same way since 2026-10-14, so its blockKey — the one a Save sends and the platform indexes — is the drawn block's.
  • Found again. A drawn block's frame carries data-block-key and data-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 merged blockRenderers), 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 is figure.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 to min(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 setComposerText give 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 merged config.markdown.blockRenderers the 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) and getBlockLanguages() (the provider calls the first; a host rarely needs them), or blockLanguages in its config.
  • The link. A block is known by (turn job, blockKey). A block's Save passes blockKey to saveArtifact against the block's jobUuid (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.changed and 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 with streaming: true and a getMessageSource that 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, jobUuid the block's turn, conversationId its conversation (not the one open), blockKey its 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 a getMessageSource of its answer when the transcript holds it, else of its own body. Its Save works as in the chat; show "Saved" from chat.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