@sqlrooms/ai-core
v0.29.0
Published
Core AI slice, chat UI primitives, and tool-streaming utilities for SQLRooms.
Readme
Core AI slice, chat UI primitives, and tool-streaming utilities for SQLRooms.
Use @sqlrooms/ai-core when you want lower-level control over AI state/transport/UI.
For most apps, use the higher-level @sqlrooms/ai package.
Installation
npm install @sqlrooms/ai-core @sqlrooms/room-store @sqlrooms/ui zod ai@sqlrooms/ui is a peer dependency used for Chat UI rendering/styling.
You typically import Chat components from @sqlrooms/ai-core, but @sqlrooms/ui must be installed for the visual components to work.
Store setup (core mode)
createAiSlice requires:
tools– an AI SDKToolSet(created via thetool()helper fromai)getInstructionstoolRenderers(optional) – aToolRendererRegistrymapping tool names to React componentsgetAvailableModels(optional) – returns selectable{provider, value}pairs so new sessions can fall back to the first available model when the configured default is missinggetCustomModel(optional) – returns a pre-constructed AI SDKLanguageModel, bypassing the OpenAI-compatible fallback entirely. Use this when a model is produced some other way, e.g. an app streaming through a server-side model proxy so no API key ever reaches the browser.
The built-in OpenAI-compatible chat transport sends images returned by tools as image attachments immediately after the corresponding tool-result batch. This avoids the adapter serializing image pixels as JSON text. Tool-call IDs and text results are preserved, and persisted chat messages are unchanged. Custom models keep their own native tool-output handling.
Send readiness (ai.hasResolvableModel()) reflects whichever of these paths can produce a model, so apps relying solely on getCustomModel do not need to register a phantom entry in @sqlrooms/ai-settings's model list just to satisfy the composer's UI check. The predicate only checks that getCustomModel was configured; it never calls it.
Its counterpart ai.requiresApiKey() reports whether the path in effect needs a browser-held key at all — false when a chatEndPoint is configured (the remote transport sends server-side, so no key reaches the browser), and false when getCustomModel is configured and currently returns a model, since that model carries its own credentials. A configured factory returning undefined still needs a key, because the transport then falls back to the built-in OpenAI-compatible client. Unlike hasResolvableModel(), this predicate therefore invokes the factory: guessing optimistically about readiness only risks a failed send, but guessing optimistically about credentials hides the only UI for entering one. The result is cached per resolved provider/model pair — keyed, so returning to a previously selected model does not re-probe — meaning a mounted composer asks the factory once per distinct selection rather than once per store update. Keep it idempotent for a given selection. The composer's needsApiKey is gated on it, so an app behind a server-side proxy is never asked for a key it has no use for.
Both model paths consult getCustomModel: the chat transport, and the one-shot ai.sendPrompt() helper. In sendPrompt a resolved custom model also wins over the modelProvider, modelName, and baseUrl options that call passes, because an app reaching its provider only through a proxy has no other usable endpoint; those options still select the API key and base URL for the fallback client, which is built only when the factory returns undefined. Unlike requiresApiKey(), sendPrompt calls the factory fresh on every request rather than reading the cached probe, so a host that swaps its model at runtime takes effect on the next send. A factory that throws is logged and treated as undefined.
Upgrading from 0.28.x? See the 0.29.0 migration guide for the full list of breaking changes:
parameters→inputSchema,component→toolRenderers,setSessionToolAdditionalDataremoved.
import {
createAiSlice,
type AiSliceState,
type ToolRendererRegistry,
} from '@sqlrooms/ai-core';
import {
BaseRoomStoreState,
createBaseRoomSlice,
createRoomStore,
} from '@sqlrooms/room-store';
import {tool} from 'ai';
import {z} from 'zod';
const EchoResult = ({
output,
}: {
output: {success: boolean; text: string} | undefined;
}) => <div>{output?.text}</div>;
type State = BaseRoomStoreState & AiSliceState;
export const {roomStore, useRoomStore} = createRoomStore<State>(
(set, get, store) => ({
...createBaseRoomSlice()(set, get, store),
...createAiSlice({
getInstructions: () => 'You are a helpful analytics assistant.',
tools: {
echo: tool({
description: 'Echo text back',
inputSchema: z.object({text: z.string()}),
execute: async ({text}) => ({success: true, text: `Echo: ${text}`}),
}),
},
toolRenderers: {
echo: EchoResult,
},
})(set, get, store),
}),
);Chat UI
import {Chat} from '@sqlrooms/ai-core';
export function AiPanel() {
return (
<Chat>
<Chat.Sessions />
<Chat.Messages />
<Chat.PromptSuggestions>
<Chat.PromptSuggestions.Item text="What trends should I investigate first?" />
</Chat.PromptSuggestions>
<Chat.Composer
placeholder="Ask a question"
topActions={<Chat.PromptSuggestions.VisibilityToggle />}
>
<Chat.Composer.Attachments />
</Chat.Composer>
</Chat>
);
}When no chat session exists, the composer and prompt suggestions share the
transient ai.draftPrompt value. ai.setDraftPrompt() can populate that draft,
and the first ai.createSession() transfers it to the new session.
Use Chat.Composer's topActions slot for compact controls that should sit in
the prompt's top row, right-aligned beside context selectors.
Attachments are opt-in. Add <Chat.Composer.Attachments /> as a composer child
to accept images and plain-text or Markdown files. Its paperclip menu shows the
supported categories and opens a type-specific file picker for each one.
Pending files render in the composer footer and are sent as AI SDK file parts.
Posted files remain part of the user message: images open at full size in a
dialog, while text and Markdown open in a scrollable text or rendered-Markdown
dialog. The default limits are four files, 1 MiB per image and across all
pending images, 128 KiB per text file, and 128 KiB across all pending text
files; the component props can override those limits. A file can be sent
without adding prompt text.
<InlineApiKeyInput>assumes session mode: it callsuseStoreWithAiunconditionally, so passing it as aChat.Composerchild underChat.LocalAgentRootthrows. Local-agent apps have no concept of a browser-held API key, so omit it there.
Composable composer and prompt-suggestions primitives
Chat.Composer and Chat.PromptSuggestions are recipes: fully-styled,
opinionated defaults built on two lower layers that are themselves public API
for hosts that need a different visual shape.
Layering, thinnest to thickest:
- Behavior hooks —
useChatComposer()andusePromptSuggestions(). No DOM, no styling. Normalized state and actions that read identically whether the surroundingChatis in session mode (Chat.Root) or local-agent mode (Chat.LocalAgentRoot). - Unstyled primitives — thin components built on the hooks above. All
accept
asChild(via Radix'sSlot) to render as a single host-supplied child instead of their default DOM element, and none carry position, size, overflow, truncation, or other visual styling of their own. - Recipes —
Chat.ComposerandChat.PromptSuggestions. SQLRooms' own opinionated, styled defaults, built entirely from the primitives above. Most apps only need these. Both supply their own state boundary, so they also work under a bareRoomStateProviderwith no<Chat>ancestor — though as siblings that gives each its own state, so a shared pre-send policy needs a common ancestor (see below).
Reach for a lower layer only when a recipe's fixed appearance does not fit — a host design system's own textarea, button, or list component, a popover-anchored suggestions panel, or a horizontal carousel instead of the vertical default.
useChatComposer() returns
{mode, prompt, setPrompt, send, cancel, canSend, isRunning, isBusy, needsApiKey, sendBlocked}.
Use it directly for anything that isn't textarea-shaped (a rich editor, a
custom input surface); Input below is built on it for the common textarea
case. It (and ChatComposerStateBoundary, for use outside any <Chat>
ancestor) is also how a suggestions list rendered elsewhere in the tree can
stay in sync with a composer mounted somewhere else.
Composer primitives (imported from @sqlrooms/ai-core, or via
Chat.Composer.Input / .Send / .Stop / .DropTarget):
Input— binds the composer's prompt to a<textarea>(or, withasChild, a host-supplied one). Owns the Enter-to-send keymap (with an IME-composition guard and a no-modifiers check), optional auto-resize, and disables itself while busy. Host event handlers passed as props are merged, not replaced: the host's handler runs first, and callingevent.preventDefault()suppresses this component's own behavior for that event — this is howsubmitOnEntercan be overridden by a host that wants full keymap control. Accepts a synchronousonBeforeSendpre-send veto (returnfalseto abort a send).The forwarded ref must reach the real DOM
<textarea>. WithasChild, auto-resize measures and mutates inlineheightthrough the ref this component receives; if the host component that owns that ref does not forward it down to its own<textarea>, auto-resize silently does nothing — no error, and nothing a type check would catch.Send— sends the current prompt on activation. Renders nothing (null) while a run is in flight; disabled whenevercanSendisfalse. Accepts the same synchronousonBeforeSendveto asInput.Both primitives render a
<button type="button">, including underasChild— an untyped HTML button defaults tosubmit, which would post an enclosing host<form>and lose the draft. AnasChildchild that sets its owntypekeeps it.Stop— cancels the in-flight run on activation. Renders nothing while idle; never disabled once a run is in flight.DropTarget— marks an element as a drop target for in-app context items dragged into the composer (built on dnd-kit). Handles in-app context items only, not file uploads — dnd-kit observes pointer-driven drags between elements it manages, not native HTML5 file-drag events; a file drop needs a separate, native-drag-based primitive. RequiresRoomDndProvider, not merely any dnd-kitDndContext: without aDndContextat all it throws rather than degrading to a no-op, and under a plain one it renders but never firesonDrop, because drops are accepted only for collisions carrying thepointerWithinmarker thatRoomDndProvider's collision detector adds.
The styled Chat.Composer.Attachments recipe is intentionally separate
from those unstyled primitives: mounting it enables the file input and footer
previews for that chat root. For a custom attachment UI, use
useChatAttachments() under the same composer state boundary. Pass
asynchronous file preparation to appendAsync so a file that finishes reading
after the user switches sessions is discarded:
const {appendAsync} = useChatAttachments();
await appendAsync(async () => [await fileToChatAttachmentPart(file)]);Pre-send policy: useRegisterBeforeSend
onBeforeSend on Input and Send vetoes sends from that control. When the
policy belongs to the chat rather than to one button — "create an artifact
before the first message", "route this through my own session management" —
register it on the composer state instead:
useRegisterBeforeSend(
useCallback((text: string) => (isAllowed(text) ? undefined : false), []),
);Every send routed through useChatComposer()'s send then consults it,
whatever triggered it — the composer's own controls, a prompt suggestion row, a
command. This is what Chat.Composer's onRun prop is built on, and why
clicking a suggestion cannot bypass a veto the composer enforces. The handler
is synchronous; return false to abort.
The registry is one per <Chat> root, which is what gives it that reach.
Two Chat.Composers under one root are two views of one chat — same session,
same prompt — so a policy registered by either applies to sends from both;
independent surfaces need their own root, and a duplicate onRun warns in
development rather than merging silently.
Sharing is by React ancestry, which has one consequence worth knowing when
using the recipes standalone. Chat.Composer and Chat.PromptSuggestions
each supply their own state boundary when no <Chat> is above them, so as
siblings under a bare RoomStateProvider they get separate registries and a
suggestion click will not run the composer's onRun. Give them a common
<Chat> or <ChatSuggestionsStateBoundary> ancestor whenever the policy has
to span both — which is also what <Chat> does for you by default.
For a state that makes sending impossible outright rather than conditionally —
a missing credential, say — use useBlockSends() instead. It reports through
useChatComposer()'s sendBlocked, so canSend and suggestion rows render
disabled rather than looking live and doing nothing. Chat.Composer uses it
when it swaps to inline API-key entry.
usePromptSuggestions() returns
{mode, visible, setVisible, toggle, items, isSessionEmpty, fill, send, isReadyToSend},
normalized the same way across both runtime modes. send/isReadyToSend reuse
the composer's own send action and readiness signals, so a suggestion and the
composer's send control can never disagree about whether sending is currently
possible — and any registered pre-send veto applies to both.
isSessionEmpty is true only when the chat has no messages and no
in-progress prompt, counting a draft typed before any session exists. Branch on
it to show suggestions only on a genuinely empty chat.
Suggestions primitives:
Root— visibility gate: renders nothing when suggestions are hidden, its child otherwise. Accepts anopenoverride for hosts whose own popover, dropdown, or overlay already owns open/closed state; pair it withonOpenChange, since while controlled theVisibilityToggleandDismisscontrols inside this root report through that callback instead of writing the storeopenoverrides. Controls rendered outside a controlled root keep targeting the store, so controlling one list does not retarget unrelated toggles.Item— a single suggestion. Fills the prompt on activation by default; passsubmitto send immediately instead. Disabled wheneverisReadyToSendisfalse.VisibilityToggle— toggles visibility; exposesaria-pressedfor styling pressed/unpressed. Inside a controlledRootit writes throughonOpenChangerather than the overridden store, but since such a root renders nothing while hidden it can only close from there — render it outside the root for a control that also re-opens.Dismiss— hides suggestions unconditionally (unlikeVisibilityToggle, it never re-shows them).
useControlledVisibility() returns the nearest controlled Root's
{visible, setVisible}, or null when visibility is owned by the normalized
store. A host writing its own visibility control needs it: writing
usePromptSuggestions().setVisible(false) directly has no effect inside a
controlled root, whose open overrides the store. Prefer the controlled state
when it is present and fall back to the store otherwise, which is what
Dismiss and VisibilityToggle do.
None of the suggestions primitives carry position, size, overflow,
truncation, or tooltip styling — a host's own vertical list, popover, or
horizontal scroller owns all of that. examples/ai-rag builds a horizontal
carousel directly from these primitives (composed with @sqlrooms/ui's
ScrollableRow) — it stays in the repo specifically to prove the primitives
impose no layout of their own, alongside examples/ai's use of the vertical
recipe.
Breaking changes in this release
- Local-agent
Enterwhile streaming no longer stops the run. It is now a no-op, matching session mode:Entersends when ready, and never cancels a run in flight. Chat.PromptSuggestionsnow defaults to a full-width vertical list with click-to-send and CSS-ellipsis truncation (plus a nativetitlefor the full text), replacing the previous horizontal card carousel that filled the prompt for editing and truncated by character count. A horizontal layout is still available — build it from the suggestions primitives, asexamples/ai-ragdoes.
Customizable chat presentation
SQLRooms owns the semantic model for each turn (activity, status, reasoning,
outputs, timing, nesting, chronological grouping, hoist eligibility). Host apps
optionally own presentation (layout, disclosure, labels, styling, visibility)
through Chat.Rendering.
Most apps keep the polished default recipe and only need <Chat.Messages />.
Missing slots always inherit SQLRooms defaults; nested providers merge rather
than replace the parent recipe.
How <Chat.Messages /> uses the slots
<Chat.Messages /> is the session list surface: it derives chat turns from the
current session's uiMessages and renders one ChatTurnView per turn. It does
not own chrome for prompts, reasoning, tools, or answers.
Each ChatTurnView builds the turn's semantic model (what happened, in what
order, what is hoistable / running / completed), then asks the nearest
Chat.Rendering recipe to present it. The recipe is a map of slot components:
| Slot | Role |
| --------------- | ------------------------------------------------------------- |
| ActiveStatus | Current in-flight run status and elapsed-time presentation |
| Turn | Full turn layout recipe — composes pre-wired semantic regions |
| Prompt | User prompt and attachment chrome for the turn |
| Activity | Collapsible / status chrome around in-progress work |
| Reasoning | Model thinking / reasoning disclosure |
| ToolActivity | One tool (or nested agent) line inside Activity |
| TextOutput | Assistant text / markdown answer regions |
| HoistedOutput | Rich tool UI lifted out of the activity stream |
| Error | Turn-level error feedback |
| Actions | Available per-turn operations such as copy and fork |
Composition looks like this:
Chat
└─ Chat.Rendering ← optional; supplies / merges slot components
└─ Chat.Messages ← lists turns from the session
└─ ChatTurnView ← builds semantic model, then renders Turn
└─ Turn ← composes pre-wired regions
├─ Prompt
├─ Timeline ← default source-order body
├─ Activity ← aggregated activity for custom layouts
├─ Response / Summary
├─ HoistedOutputs
├─ Error
└─ ActionsSo:
- Wrap
<Chat.Messages />in<Chat.Rendering>to change presentation for that subtree (or nest providers to layer overrides). - Override leaf slots (
Prompt,Reasoning, …) when you only need different look for those regions;Turnkeeps deciding where they appear. - Override
Turnwhen the regional structure itself must change. It receives pre-wired region components, so it can reorder or omit regions without rebuilding search ids, tool classification, or leaf-slot props.
Without a Chat.Rendering ancestor, ChatTurnView uses the built-in SQLRooms
defaults for every slot.
Override a single slot
Pass a partial components map. Only the slots you provide change; everything
else keeps the SQLRooms (or parent) recipe.
Activity chrome only — keep default turn order and prompt/reasoning look:
import {Chat, type ChatActivityProps} from '@sqlrooms/ai-core';
function AppActivity({children, isRunning, summaryLabel}: ChatActivityProps) {
return (
<section data-running={isRunning}>
{summaryLabel ? <header>{summaryLabel}</header> : null}
{children}
</section>
);
}
export function AiPanel() {
return (
<Chat>
<Chat.Rendering components={{Activity: AppActivity}}>
<Chat.Messages />
</Chat.Rendering>
</Chat>
);
}Prompt look only — restyle the user message bubble without touching turn layout or tool activity:
import {
Chat,
HighlightedChatSearchText,
type ChatPromptProps,
} from '@sqlrooms/ai-core';
function AppPrompt({prompt, searchBlockId}: ChatPromptProps) {
return (
<blockquote className="border-l-2 pl-3">
<HighlightedChatSearchText text={prompt} blockId={searchBlockId} />
</blockquote>
);
}
<Chat.Rendering components={{Prompt: AppPrompt}}>
<Chat.Messages />
</Chat.Rendering>;Reasoning look only — change thinking disclosure while keeping default activity boxes and answer rendering:
import {
Chat,
HighlightedChatSearchText,
type ChatReasoningProps,
} from '@sqlrooms/ai-core';
function AppReasoning({text, isRunning, searchBlockId}: ChatReasoningProps) {
return (
<aside aria-busy={isRunning}>
<strong>{isRunning ? 'Thinking…' : 'Thoughts'}</strong>
<pre>
<HighlightedChatSearchText text={text} blockId={searchBlockId} />
</pre>
</aside>
);
}
<Chat.Rendering components={{Reasoning: AppReasoning}}>
<Chat.Messages />
</Chat.Rendering>;You can combine any of these in one components map by providing both the
Prompt and Reasoning entries.
Active run status — change or suppress the progress indicator without
reimplementing chat-run state derivation. The exported getChatActiveStatus
helper is also available when the indicator needs to be placed outside
Chat.Messages:
import {Chat, type ChatActiveStatusProps} from '@sqlrooms/ai-core';
function AppActiveStatus({status}: ChatActiveStatusProps) {
return <MyProgressIndicator label={status.label} />;
}
<Chat.Rendering components={{ActiveStatus: AppActiveStatus}}>
<Chat.Messages />
</Chat.Rendering>;Set ActiveStatus to a component that returns null when the host owns the
indicator's placement entirely. For that case, call getChatActiveStatus with
the current messages and ToolRenderBehavior to reuse the same status model.
TextOutput receives isAnswer=true only for text that is the final message
part. Planning text followed by tool activity remains regular response text.
Tool activity — customize top-level and nested tool or agent rows through
one normalized contract. toolCall is always present; part is only present
when the activity originated from a top-level AI SDK message part:
import {Chat, type ChatToolActivityProps} from '@sqlrooms/ai-core';
function AppToolActivity({toolCall, isAgent}: ChatToolActivityProps) {
return (
<div data-state={toolCall.state}>
{isAgent ? 'Agent' : 'Tool'}: {toolCall.toolName}
</div>
);
}
<Chat.Rendering components={{ToolActivity: AppToolActivity}}>
<Chat.Messages />
</Chat.Rendering>;The same recipe also applies inside nested agents. Similarly, an Activity
override controls their activity chrome when nestedActivityMode="own-boxes".
For agent rows, toolCall.agentToolCalls follows live agent progress and then
the persisted final output, so the same renderer works during streaming.
Action controls — compose available operations without recreating their
behavior. Optional copy and fork objects are capabilities: omitting one
hides it, while its Content keeps the built-in control wired correctly:
import {Chat, type ChatActionsProps} from '@sqlrooms/ai-core';
function AppActions({copy}: ChatActionsProps) {
const Copy = copy?.Content;
return (
<div className="flex gap-1">
{Copy && <Copy />}
<MyCustomButton />
</div>
);
}
<Chat.Rendering components={{Actions: AppActions}}>
<Chat.Messages />
</Chat.Rendering>;Apps can replace a control's visuals while preserving its behavior:
function AppActions({fork}: ChatActionsProps) {
return fork ? <MyForkButton onClick={fork.run} /> : null;
}An absent capability cannot be enabled accidentally: fork is only present
when the turn can actually be forked.
Fully custom turn layout
Override Turn when you need a different regional order or structure (for
example activity → response → hoisted → summary). Pair with
nestedActivityMode="embed" when nested agents should contribute log lines
into the parent Activity instead of creating their own boxes. Each region
exposes semantic data and a pre-wired Content component, so custom layouts
can inspect status and items without rebuilding search, tool, or hoist wiring:
import {Chat, type ChatTurnSlotProps} from '@sqlrooms/ai-core';
function AppChatTurn({turn}: ChatTurnSlotProps) {
const Prompt = turn.prompt.Content;
const Activity = turn.activity.Content;
const Response = turn.response.Content;
const HoistedOutputs = turn.hoistedOutputs.Content;
const Summary = turn.summary.Content;
const Error = turn.error?.Content;
const Actions = turn.actions.Content;
return (
<article data-turn-id={turn.id} aria-busy={!turn.isCompleted}>
<Prompt />
<Activity />
<Response />
<HoistedOutputs />
<Summary />
{Error && <Error />}
<Actions />
</article>
);
}
<Chat.Rendering
nestedActivityMode="embed"
components={{Turn: AppChatTurn, Prompt: AppPrompt, Reasoning: AppReasoning}}
>
<Chat.Messages />
</Chat.Rendering>;Errors are deliberately separate from actions in the turn model. The default
layout keeps them adjacent, but a custom Turn may place or omit each region
independently.
Activity and hoisted-output regions are also disjoint: nested agent activity
contains status/log presentation, while nested rich outputs are rendered once
through turn.hoistedOutputs in decomposed layouts. The pre-wired Timeline
keeps owning both when the default source-order layout is used.
For finer composition, iterate semantic items and render their pre-wired leaf components. Tool items expose state, agent, and hoist metadata:
function AppActivity({turn}: ChatTurnSlotProps) {
return turn.activity.items.map((item) => {
const Content = item.Content;
return (
<section
key={item.id}
data-state={item.kind === 'tool' ? item.state : undefined}
>
<Content />
</section>
);
});
}Pair a custom Turn with leaf-slot overrides as needed. Prefer keeping the
default turn (or a host-owned turn recipe) and swapping only the slots that
must differ, unless the regional order itself has to change.
Chat.Header and Chat.History can delegate session creation to the host app
with onCreateSession. Chat.History also accepts filterSession and
emptyLabel so apps can present scoped histories without changing the generic
AI session schema.
createAiSlice({onChatFinish}) lets host apps observe a non-aborted turn after
the completed messages have been persisted and the analysis has ended. Use this
for app-owned follow-up behavior, such as forking a completed chat into a new
workspace target, while keeping the generic AI slice unaware of app-specific
state.
Optional timeout safety limits
Chat and tool timeouts are disabled by default. Apps can opt into a generous
run limit, an idle-stream watchdog, and tool execution limits through
createAiSlice. Timeout options are runtime behavior and are not persisted in
workspace configuration.
createAiSlice({
tools,
getInstructions,
// Hybrid tools that omit `execute` on the configured remote endpoint.
remoteClientToolNames: ['weather'],
timeouts: {
runMs: 30 * 60_000,
idleStreamMs: 5 * 60_000,
toolExecutionMs: 5 * 60_000,
tools: {
query: undefined, // Allow long-running queries.
fetchMetadata: 30_000,
},
},
});runMs covers the complete multi-step run. idleStreamMs resets when the UI
receives observable message progress and pauses while waiting for tool
approval. A silent operation that is still working is indistinguishable from a
stalled operation, so idle timeouts should remain conservative. Tool timeouts
abort the signal passed to the tool and fail its pending call; tools should
honor abortSignal to stop their underlying work promptly. Local executable
tools and registered tools awaiting client output are covered, including hybrid
client tools named by remoteClientToolNames whose remote definition omits
execute. Remote endpoints remain responsible for enforcing timeouts around
tools they execute server-side.
Assistant messages can be forked into a new active chat through
ai.forkSessionFromMessage(). The action snapshots the source session's
uiMessages through the selected message or chat turn, inherits the source
session's model and draft context item ids, records sessionForks provenance,
and lets Chat.Messages show a Forked from link back to the source chat.
Use generateSessionTitle when apps want an imperative helper that turns a
session's early user messages into a concise title via ai.sendPrompt, cleans
the model output, and renames the session. Use useGenerateSessionTitle in React
surfaces that should watch the current session and trigger the helper after new
user messages. The hook handles debouncing and duplicate-generation guards.
Apps can pass enabled, isDefaultSessionName, and getPromptOptions to keep
app-specific readiness checks and model choices outside the shared package.
Local Agent Chat
Use Chat.LocalAgentRoot when a transient surface should be driven by a
pre-constructed ToolLoopAgent instead of the session-backed AI slice. The
message and composer components stay under the same Chat compound API.
<Chat.LocalAgentRoot
agent={agent}
initialSuggestions={['Get started', 'Show me an example']}
onMessagesChange={(msgs) => console.log(msgs)}
>
<Chat.Messages />
<Chat.PromptSuggestions />
<Chat.Composer placeholder="Ask anything..." />
</Chat.LocalAgentRoot>Chat runtime providers
Chat.Root and Chat.LocalAgentRoot render one of two runtime providers that
the Chat message and composer components read via useChatRuntime():
SessionChatRuntimeProvider— the default. Selects the session runtime, so the UI is driven by the session-backed AI slice (Chat.Rootwraps this).LocalAgentChatRuntimeProvider— selects the local-agent runtime, driving the same UI from a pre-constructedToolLoopAgentwith its own local message state (Chat.LocalAgentRootwraps this). It takes the same props asChat.LocalAgentRoot(agent,initialMessages,initialSuggestions,onMessagesChange).
The two are mutually exclusive modes, not layers: a Chat subtree is wrapped by
exactly one of them. Most apps use Chat.Root / Chat.LocalAgentRoot and never
touch these directly; they are exported for advanced setups that compose the
runtime themselves. Both must wrap the Chat components that call
useChatRuntime(); used outside a provider, the runtime defaults to session mode.
Session chat execution itself is owned by the AI slice, independently of these
presentation providers. startAnalysis(sessionId) lazily creates an ephemeral
chat runtime for the session, so a run can continue when no React chat surface is
mounted. useSessionChat(sessionId) only subscribes React to the runtime's chat.
Persisted messages and session state remain in ai.config, while SDK chat
instances, subscriptions, and timers remain ephemeral.
Chat search
Chat renders a ChatSearchProvider and exposes Chat.Search, an in-conversation
find bar that highlights matches in the current session's messages.
For building search UIs outside the chat (e.g. a session list that searches across all sessions), the underlying matching primitives are exported and can be used without the provider:
normalizeChatSearchQuery(query)— trims + lower-cases a query (the casing rule the search uses).findChatSearchMatches(blocks, query)— returns positional matches (ChatSearchMatch[]) for a list ofChatSearchBlocks. Useful for highlighting matched substrings consistently withChat.Search.markdownToPlainText(markdown)— extracts plain text from markdown so message content can be made searchable.
import {findChatSearchMatches, type ChatSearchBlock} from '@sqlrooms/ai-core';
const blocks: ChatSearchBlock[] = [
{id: 'title', resultId: 'title', text: title},
];
const matches = findChatSearchMatches(blocks, query);Keeping highlighting in a replaced slot. A host that swaps out a chat leaf
slot renders its own text, so it loses the highlighting the default slot got for
free. HighlightedChatSearchText restores it. Pass the same blockId the turn
model registered for that part; without it there are no matches to highlight and
the component renders the text unchanged. The Prompt slot example above shows
the shape.
Matches are wrapped in <mark>, and the active match carries the match id as its
DOM id, so a host can scroll it into view. useOptionalChatSearch() exposes the
same state directly (activeMatchId, getMatchesForBlock) for slots that need
to do their own anchoring. It returns null outside a ChatSearchProvider, so a
component rendered away from Chat.Root degrades instead of throwing.
Indexing follows what actually rendered, not just what got registered. A block
that a slot returns null for, or that stays hidden behind a user preference,
never mounts HighlightedChatSearchText (or the equivalent reporting) and so
contributes no matches. Search never points at content the user can't see.
The text a slot renders is also the text that gets indexed: HighlightedChatSearchText
matches and highlights against the text prop it was given, so a slot showing
a transformed or shortened string is searchable by what it actually displays,
not by whatever text the block was originally registered with.
Custom Markdown components are opaque rendering boundaries. Text beneath an
overridden Markdown element is excluded from automatic search because the
component may replace or hide its children; other default-rendered text in the
same message stays searchable. Overriding mark disables automatic search for
that message because generated highlights may never reach the DOM.
A slot that paints its own matches instead of rendering through
HighlightedChatSearchText must call useReportRenderedChatSearchBlock(blockId)
itself, or its block never counts as rendered and contributes no matches.
A slot that hides its content behind a disclosure or a toggle can key an effect
on useActiveChatSearchMatchKey(blockId) to reveal that content for every
selection attempt, including repeated navigation to the same match. This keeps
scrolling from landing on something still hidden and is what the default
reasoning disclosure uses to open itself.
Devtools
@sqlrooms/ai-core/devtools exposes development-only inspection helpers and UI
for session debugging without adding the debug surface to the main
@sqlrooms/ai-core barrel.
import {ChatSessionDebugView} from '@sqlrooms/ai-core/devtools';
function DebugPanel({
sessionId,
onClose,
}: {
sessionId: string;
onClose?: () => void;
}) {
return <ChatSessionDebugView sessionId={sessionId} onClose={onClose} />;
}ChatSessionDebugView reads the current AI store context and renders a tabbed
debug view for one chat session: the chronological timeline, registered tools,
run context, raw session JSON, model settings, tool calls, nested agent work,
and optional agent snapshots.
Agent snapshot capture is opt-in on createAiSlice:
createAiSlice({
tools,
getInstructions,
devtools: {
captureAgentSnapshots: true,
persistAgentSnapshots: true,
maxAgentSnapshotBytes: 64_000,
},
});Snapshots are bounded serializable metadata only: agent/tool names, descriptions, capability flags, approval hints, and settings. They must not store executable tools, closures, secrets, or unbounded prompt/output content. Persist snapshots only for debugging workflows where cross-tab or post-mortem inspection is useful.
Captured snapshots and snapshot controls live under the AI state's devtools namespace:
const snapshots = useRoomStore((state) => state.ai.devtools.agentSnapshots);
state.ai.devtools.clearAgentSnapshots();Provider-context diagnostics
Provider-context capture is also opt-in. Enable it when creating the AI slice;
records are kept in memory and the oldest record is discarded when the limit
is exceeded (the default limit is 100):
createAiSlice({
tools,
getInstructions,
devtools: {
captureProviderContexts: true,
maxProviderContextRecords: 100,
},
});
const providerContexts = useRoomStore(
(state) => state.ai.devtools.providerContexts,
);
roomStore.getState().ai.devtools.clearProviderContexts();Each ProviderContextDiagnostic describes one provider step using metadata
only: role, provider/model, session and step identifiers, instruction/message
sizes, tool names and schema sizes, source labels, optional preparation
metrics, and provider-reported input tokens when available. Raw instructions,
messages, and tool schemas are not copied into the record. The records are
transient devtools state and are also shown by ChatSessionDebugView.
Core chat and ai.sendPrompt() provider steps are captured automatically when
the option is enabled. Custom or nested agents can use
state.ai.devtools.measureProviderContext(input) to measure and append the same
record shape while respecting the capture flag. The main package also exports
measureProviderContext, tryMeasureProviderContext,
MeasureProviderContextArgs, and ProviderContextDiagnostic for integrations
that need to measure outside a slice method. measureProviderContext is the
strict helper and rejects measurement failures; tryMeasureProviderContext is
the fail-open request-path helper that logs a warning and returns undefined
so diagnostics cannot abort a valid provider request. Callers using either
standalone helper are responsible for writing the returned record to their
chosen diagnostics store.
Useful exports
- Slice/hooks:
createAiSlice,useStoreWithAi,generateSessionTitle,useGenerateSessionTitle,AiSliceState - Chat UI:
Chat,ChatMessagesContainer,ChatTurnView,MessageContent,ModelSelector,QueryControls,PromptSuggestions - Block Ask AI:
BlockAiPromptPopover,createAskAiBlockHeaderAction,AskAiBlockHeaderActionRenderContext,CreateAskAiBlockHeaderActionOptions - Devtools subexport:
@sqlrooms/ai-core/devtools - Legacy/compat components:
AnalysisResultsContainer,AnalysisResult,AnalysisAnswer,ErrorMessage - Session helpers:
ChatSessionSchema,isChatSessionEmpty,getChatTurnsFromUiMessages - Forking:
ai.forkSessionFromMessage(),AiSessionForkOrigin,ForkSessionFromMessageArgs - Types:
ChatTurn,ToolRendererProps,ToolRenderer,ToolRendererRegistry,StoredTool,StoredToolSet - Provider diagnostics:
measureProviderContext,tryMeasureProviderContext,MeasureProviderContextArgs,ProviderContextDiagnostic - Tool/agent utilities:
cleanupPendingUiMessagescleanupPendingAnalysisResultsfixIncompleteToolCallsstreamSubAgentwithRunContextTools
Forwarding execution scope into nested agents
Top-level tools receive the invoking turn's sessionId and AiRunContext
because the chat transport wraps state.ai.tools with withRunContextTools.
Tools handed to a nested ToolLoopAgent are not wrapped by the transport, so
without the same wrapper they execute with no scope and any target resolution
that defaults to "the current artifact/map" silently follows mutable UI
selection instead of the artifact captured when the prompt was submitted.
Wrap nested toolsets with the parent's execution options:
import {withRunContextTools} from '@sqlrooms/ai-core';
const nestedTool = tool({
inputSchema,
execute: async (input, options) => {
const agent = new ToolLoopAgent({
model,
instructions,
// `options` carries the parent turn's scope; forward it verbatim.
tools: withRunContextTools(
nestedTools,
options as AiToolExecutionContext,
),
});
return streamSubAgent(
agent,
prompt,
store,
options.toolCallId,
// The wrapper preserves `abortSignal` for the nested tools, but the
// sub-agent loop itself only observes cancellation through this argument.
options.abortSignal,
);
},
});Forward options.abortSignal to streamSubAgent. withRunContextTools leaves
the signal intact for the tools the sub-agent calls, but the agent loop —
including the model stream and any approval wait — is only cancellable through
streamSubAgent's fifth argument, so omitting it lets a stopped parent turn
leave the nested run going.
When a sub-agent stream fails, streamSubAgent throws SUB_AGENT_ERROR_MESSAGE rather than the provider's own text, and logs the raw error to the console. Callers serialize the thrown message into a tool result that the parent model receives, and a child agent can run on a different provider than its parent, so an exception carrying an endpoint, account detail, or credential would otherwise cross that boundary. A host that has verified parent and child share a trust boundary can pass {formatError: getSubAgentErrorMessage} as the sixth argument to get the underlying text back, or supply its own formatter. Local diagnostics are unaffected either way.
Pass the parent's getAiRunContext (not a copied aiRunContext) so an in-turn
retarget via set_primary_context_artifact is visible to subsequent nested tool
calls. Parent scope wins over inner options, so a nested agent cannot reassign
the owning session; fields the parent leaves undefined are preserved.
AnalysisSessionSchema, isAnalysisSessionEmpty, AnalysisResultsContainer,
AnalysisResult, AnalysisAnswer, processAnalysisAnswerContent, and
cleanupPendingAnalysisResults remain compatibility exports for existing apps.
New code should prefer ChatSessionSchema, isChatSessionEmpty,
Chat.Messages, ChatTurnView, MessageContent, uiMessages, and derived
ChatTurn helpers.
Legacy persisted sessions that contain analysisResults still load through
schema migration, but parsed and newly created chat sessions no longer include
that field.
Related packages
@sqlrooms/ai(recommended high-level integration)@sqlrooms/ai-settings(provider/model settings slice + UI)@sqlrooms/ai-config(config schemas and migrations)
