Chat
There is one chat component. ChatWidget is the full-control component: streaming text, tool-call cards, confirmations, ask-user cards, session history, theming, and an imperative handle. AmodalChat (in the package root) is a preset of the same component: ChatWidget with position="inline" and serverUrl defaulted from the nearest AmodalProvider. Pick the entry point, not a different widget.
ChatWidget is exported from both the package root and the @amodalai/react/widget subpath; the subpath exists so a chat-only embed doesn't import the whole SDK, and it also ships the widget's building blocks for composing a custom chat surface (see Building blocks).
Installation
npm install @amodalai/reactQuick start
Inside an AmodalProvider, the preset is enough:
import { AmodalChat } from "@amodalai/react";
import "@amodalai/react/style.css";
<AmodalChat user={{ id: userId }} />;For any other position, or outside a provider, use ChatWidget directly:
import { ChatWidget } from "@amodalai/react/widget";
import "@amodalai/react/widget/style.css";
<ChatWidget
serverUrl="https://your-agent.example.com"
user={{ id: userId }}
position="floating"
historyEnabled
/>;Props
AmodalChat accepts every ChatWidget prop except position (pinned to inline); its serverUrl is optional and defaults to the provider's runtimeUrl.
Connection and identity
| Prop | Default | Meaning |
|---|---|---|
serverUrl | required | Base URL of the agent's runtime server. |
user | none | { id: string }; client-side label only — not sent to the runtime, which derives identity from the auth session. |
getToken | none | Returns a bearer token for authenticated requests (sync or async). |
scopeId | none | Multi-tenant or per-object isolation: labels sessions and partitions memory/stores per value. |
scopeContext | none | Key-value pairs injected into connection API calls via contextInjection. |
scopeId and sessionId are related but not interchangeable. scopeId tells the runtime which tenant, user, workspace, case, or record the agent is operating inside. The active sessionId is the exact conversation. Passing the same scopeId keeps data access scoped, but it does not by itself resume the previous chat. Use resumeSessionId or persist the id from onStateChange when the same thread should survive page navigation.
Behavior
| Prop | Default | Meaning |
|---|---|---|
position | floating | See Positions. |
defaultOpen | false | Start open (togglable positions only). |
historyEnabled | false | Session history drawer. |
showHeader / showInput / showFeedback | true / true / false | Header bar; input bar; thumbs up/down on assistant messages. |
sessionType | none | Which curated surface (skills, tools, knowledge) loads into the session. |
deployId | active deploy | Pin a specific deployment. |
initialMessage | none | Auto-sent once on mount. |
resumeSessionId | none | Load an existing session on mount and continue sending on that session; takes precedence over initialMessage. |
Callbacks
| Prop | Meaning |
|---|---|
onToolCall(call) | A tool call completed; receives the full ToolCallInfo. |
onKBProposal(proposal) | The agent proposed a knowledge-base update. |
onEvent(event) | Every widget event (agent-driven and interaction). |
onSessionCreated(sessionId) | First stream init returned a session id. |
onStreamEnd() | The SSE stream ended. |
onStateChange({ sessionId, messages }) | Session state changed (for external persistence). |
Extension points
| Prop | Meaning |
|---|---|
theme | See Theming. |
widgets | WidgetRegistry of custom renderers for rich inline widgets. |
inlineBlockRenderers | Renderers for block types the widget doesn't render natively; native types (text, ask_choice, proposal) cannot be overridden. |
streamFn | Custom transport ((text, signal, images?, attachments?) => AsyncIterable<SSEEvent>); replaces the built-in chat API call for non-standard endpoints. |
entityExtractors | Replaces the default entity extractor for entity events. |
Imperative handle
The ref exposes ChatWidgetHandle:
const chat = useRef<ChatWidgetHandle>(null);
chat.current?.sendMessage("Summarize today's alerts"); // as if the user typed it
chat.current?.getSessionId();Keeping A Scoped Chat Alive
For product embeds, the common pattern is one chat thread per app scope. Store the session id under the scope id and restore it when that panel remounts:
const storageKey = `amodal-session:${scopeId}`;
const [resumeSessionId] = useState(() => localStorage.getItem(storageKey));
<ChatWidget
serverUrl={serverUrl}
user={{ id: userId }}
scopeId={scopeId}
scopeContext={{ tenant_id: tenantId, page: "case-review" }}
resumeSessionId={resumeSessionId ?? undefined}
onStateChange={({ sessionId }) => {
if (sessionId) localStorage.setItem(storageKey, sessionId);
}}
/>;Use a different storage key when you want a different conversation boundary. For example, tenant:${tenantId} creates one assistant thread for the whole tenant, while case:${caseId} creates a separate assistant thread for each case.
Positions
| Position | Behavior |
|---|---|
inline | Renders in-place within your layout |
floating | Floating button that expands into a chat panel |
right | Fixed panel on the right side |
bottom | Fixed panel at the bottom |
Theming
The theme prop covers the common cases:
<ChatWidget
serverUrl={serverUrl}
user={{ id: userId }}
theme={{
mode: "auto", // 'light' | 'dark' | 'auto' (follows prefers-color-scheme)
primaryColor: "#6e56cf",
borderRadius: "12px",
headerText: "Ask the agent",
placeholder: "Type a message…",
verboseTools: true, // full tool-call params, results, timing
}}
/>Other ChatTheme fields: backgroundColor, fontFamily, fontSize, userBubbleColor, agentBubbleColor, toolCallColor, emptyStateText.
Which key drives which surface
Each theme key sets one CSS custom property that drives specific surfaces. primaryColor is shared: the floating launcher, the send button, focus rings, and accent text all read --pcw-primary, so you cannot recolor the launcher independently through the prop (override the CSS variable on a narrower selector for that).
theme key | CSS variable | Surfaces it controls |
|---|---|---|
primaryColor | --pcw-primary | Floating launcher, send button, focus rings, accent text/links |
backgroundColor | --pcw-bg | Panel and header background |
userBubbleColor | --pcw-user-bubble | User message bubbles |
agentBubbleColor | --pcw-agent-bubble | Agent bubbles and inline cards |
toolCallColor | --pcw-tool-call-bg | Tool-call card background |
borderRadius | --pcw-radius | Panel, bubbles, buttons |
fontFamily / fontSize | --pcw-font / --pcw-font-size | All widget text |
headerText | --pcw-header-text † | Header title label |
placeholder | --pcw-placeholder † | Input placeholder |
emptyStateText | --pcw-empty-state-text † | Empty-conversation message |
mode | data-theme / prefers-color-scheme | Light vs. dark palette (see below) |
† These variables carry a text string, not a style value, and the text is rendered by the component rather than read back from the variable. Set the prop to change the copy; overriding the CSS variable alone has no effect.
Surfaces with no matching theme key (header/body text, muted text, borders, panel tint, shadow) are CSS-only: --pcw-text, --pcw-text-muted, --pcw-border, --pcw-panel-tint, --pcw-user-text, --pcw-shadow. Override those directly.
For anything the prop doesn't cover, override the CSS custom properties (no Tailwind dependency):
.pcw-widget {
--pcw-primary: #6e56cf;
--pcw-bg: #ffffff;
--pcw-text: #1a1a1a;
--pcw-border: #e5e5e5;
--pcw-radius: 12px;
}Console Embed page vs. props
The console's Embed page stores a widget configuration server-side (position, theme, toggles) so operators can tune the widget without a code commit. That config does two things:
- It drives the Preview on the Embed page.
- It generates the React snippet you copy into your host app.
A self-mounted <ChatWidget> never fetches it at runtime. The snippet is a point-in-time copy — the server values are baked into the props when you copy it — and once mounted the widget reads only its props. The props are the single source of truth; there is no precedence to resolve.
Practical consequences:
- Changing the Embed config later does not update an already-shipped snippet. Re-copy the snippet (or wire the values into your own config) to pick up changes.
- Any prop you edit by hand after copying wins, because it is the only value the widget sees.
SSE events
The widget handles these event types from the runtime:
| Event | Description |
|---|---|
text_delta | Streaming text output |
tool_call_start | Tool execution beginning |
tool_call_result | Tool execution complete |
skill_activated | Skill activation |
widget | Widget rendered inline |
confirmation_required | Write operation needs approval |
done | Response complete |
Building blocks
For a custom chat surface, the /widget subpath exports the pieces ChatWidget is made of:
- Components:
MessageList,InputBar,SessionHistory,StreamingIndicator,ToolCallCard,AskUserCard,KBProposalCard,SkillPill,TagEditor,FormattedText, andWidgetRenderer(with itsWidgetRegistrytype). - Hooks:
useChat(the widget's chat state machine),useWidgetEvents,useSessionHistory. - Chat API:
listSessions,getSessionHistory,createSession. - Theme utilities:
defaultTheme,applyTheme,mergeTheme.
The provider-based hooks and headless clients live in the package root; see @amodalai/react.