@amodalai/react
React bindings for a deployed Amodal runtime. The package has three layers, and most real apps use all of them:
- Provider + hooks:
AmodalProvidergives every hook a configured client; hooks read stores, fire intents and skills, follow sessions, and subscribe to live events. - Chat UI:
ChatWidget(and its presetAmodalChat) renders a full streaming chat with tool calls, confirmations, and widgets. Detailed on the Chat page. - Headless clients:
SdkClient,RuntimeClient, and the SSE/chat-api utilities, for when a hook doesn't fit (imperative code, non-React state layers).
Installation
npm install @amodalai/reactAmodalProvider
Wraps your app and provides the configured clients to every hook:
import { AmodalProvider } from "@amodalai/react";
<AmodalProvider runtimeUrl="https://your-agent.example.com">
{children}
</AmodalProvider>;| Prop | Required | Meaning |
|---|---|---|
runtimeUrl | yes | Base URL of the agent's runtime server. |
platformApiUrl | no | Base URL of the platform API; required for OAuth connect flows (useConnections().connect). Falls back to runtimeUrl. |
getToken | no | Returns a bearer token for authenticated requests (sync or async). |
getDevUser | no | Local development only: returns a user id sent as x-amodal-dev-user; honored only when the runtime's dev-auth shim is enabled. |
For an app served by Amodal at the agent's own origin, use a same-origin runtimeUrl such as window.location.origin and omit getToken: hosted login is cookie-backed and the bearer is injected at the edge. Pass getToken only when your own backend/IdP owns identity. See Authentication.
useAmodalContext() returns the provider's internals (client: RuntimeClient, sdk: SdkClient, runtimeUrl, platformApiUrl, getToken) for anything the hooks don't cover; it throws outside a provider.
Reading and writing stores
These wrap the runtime's store API and are the primary way a custom UI shares data with the agent: the UI reads the same rows the agent's store__* tools write, with no chat round-trip.
useStoreQuery
const { data, error, isLoading, refetch } = useStoreQuery<Submission>("submissions", {
where: { status: "received" },
orderBy: "updated_at",
order: "desc",
limit: 50,
});
// data: Array<{ key: string; value: Submission; updatedAt: string }>where is an equality filter on payload fields; orderBy/order sort server-side; limit caps the result (requests above the server's 500-row page size are paged transparently). Call refetch() after an action to pull in new rows.
useStoreEntry
One document by key, plus a write-through:
const { data, isLoading, mutate, refetch } = useStoreEntry<Submission>("submissions", "sub_bistro_ember");mutate(next) POSTs the payload and refetches. The runtime derives the document key from the payload's key template, so next must include its key field(s).
useStoreActions
Imperative get / set / remove without a subscription, for event handlers:
const submissions = useStoreActions<Submission>("submissions");
await submissions.set("sub_x", row); // key derived from the payload, as aboveRunning work
useIntentRun
Fires a replay intent (defineIntent) imperatively: the typed action lane for buttons and forms. Replay intents don't return a value; they drive a durable session and write results to stores, so run resolves with { sessionId, outcome } and you read results via useStoreQuery or useSession.
const analyze = useIntentRun("analyze-submission-action");
await analyze.run({ submission_id });
// analyze.status: 'idle' | 'running' | 'succeeded' | 'failed'
// analyze.output.outcome, analyze.error, analyze.reset()run resolves when the session reaches a terminal or operator-facing state: if the runtime answers synchronously with an outcome it is used directly; a queued start is watched (polled) to its outcome. An options argument accepts scopeId and scopeContext when the intent operates on a scoped resource, mirroring the chat hooks.
useIntentTrigger is the queued lane of the same contract: it enqueues via POST /api/enqueue instead of running synchronously, then watches the session identically. Same launcher shape, so a component can swap lanes without changing its rendering. See Intents for the replay execution model.
useSkillRun
Invokes a declared skill directly and returns its structured result. Ephemeral: no session, journal, or attribution is persisted. Use it for fast, throwaway structured output ("suggest subject lines"); durable, attributed work goes through an intent.
const namer = useSkillRun<{ suggestions: string[] }>("subject-line-writer");
await namer.run({ prompt: "3 subject lines for a renewal reminder" });
// namer.result, namer.text, namer.toolCalls, namer.status, namer.errorWorked example
This submissions screen is exactly these pieces: a store query renders the rows, a replay intent backs the button, and a refetch closes the loop.
function SubmissionsScreen() {
const submissions = useStoreQuery<Submission>("submissions");
const findings = useStoreQuery<RiskFinding>("risk_findings");
const analyze = useIntentRun("analyze-submission-action");
async function onAnalyze(id: string) {
await analyze.run({ submission_id: id });
await Promise.all([submissions.refetch(), findings.refetch()]);
}
if (!submissions.data?.length) return <EmptyState hint="send `seed` in chat first" />;
return <SubmissionsTable rows={submissions.data} findings={findings.data} onAnalyze={onAnalyze} />;
}Sessions
Durable sessions are how intent runs, scheduled work, and operator pauses surface to a UI.
| Hook | What it gives you |
|---|---|
useSessions(query?) | List the caller's sessions. |
useSession(id) | Full detail for one session: status, metadata (surface, current step, pending input request, progress), messages. Poll via refetch, or pair with useAgentEvents for live updates. |
useSessionActions(id) | Operator actions: answer(value, edits?) resolves a pending requestInput (the runtime enforces eligible answerers), cancel(), restart(). |
useSessionTypes() | The deploy's author-declared session_types map, so a UI can render available curated surfaces without hardcoding names. |
useScheduledIntents() | Sessions fired by schedules (cron), for a "scheduled runs" list. |
useToolCallLog({ sessionId }) | Per-session tool-call audit log (options also accept since and limit). |
Live events
useAgentEvents subscribes to the runtime's event bus over SSE (/api/events):
const { events, latest, isConnected } = useAgentEvents({
include: ["tool_calls"], // server-side filter: categories or event-type names
sessionId, // optional client-side narrowing to one session
onEvent: (e) => { ... },
});Type predicates (isToolCallStarted, isToolCallCompleted, isToolCallFailed) narrow the discriminated AgentEvent union. Reconnects resume from the last delivered event id.
Chat components
AmodalChat and ChatWidget
One chat component with two entry points: ChatWidget is the full-control component (positions, history, theming, callbacks, custom transports), and AmodalChat is a preset of it (position="inline", serverUrl defaulted from the nearest AmodalProvider).
import { AmodalChat } from "@amodalai/react";
<AmodalChat user={{ id: userId }} />;Props, positions, theming, and the widget's building blocks are documented on the Chat page.
AmodalAction
Fires a chat prompt from a button or other UI element:
import { AmodalAction } from "@amodalai/react";
<AmodalAction prompt="Summarize today's alerts">Get Summary</AmodalAction>;Note the division of labor: AmodalAction sends a natural-language prompt through chat; useIntentRun fires a typed replay intent with no LLM in the loop. Product actions that must be deterministic belong on useIntentRun.
Confirmation and review cards
The chat surfaces render these automatically when the agent needs approval for a write; they're also exported for standalone flows built on the chat stream's confirmation_required events:
ConfirmCard: endpoint, method, reason, approve/deny.ReviewCard: the structured variant; full parameters, escalation badge, connection name.
Both take { confirmation, onApprove, onDeny }.
FormattedMarkdown (a prose-styled markdown renderer) is exported too, for rendering agent output outside the chat surfaces; inside ChatWidget, chat messages render with FormattedText.
Chat hooks
For a custom chat surface without ChatWidget:
| Hook | Description |
|---|---|
useAmodalChat | Provider-based chat session: send messages, streamed state, confirmation callbacks. |
useChat / useChatStream | The widget's own lower-level chat state machine, serverUrl-based. |
useSessionHistory | List and load previous chat sessions. |
And four single-shot conveniences: useAmodalBrief (fetch a summary on mount), useAmodalInsight, and useAmodalQuery each wrap one chat exchange over RuntimeClient.chatStream; anything they do, a chat surface or useAmodalChat can do. useAmodalTask is the exception: it wraps the runtime's fire-and-forget task API (POST /task, streamed via GET /task/:id/stream) to start and track a background task.
Headless clients
Everything the hooks do is available imperatively:
SdkClient(viauseAmodalContext().sdkor constructed directly):runIntent,triggerIntent,watchSession,runSkill, session actions, store access via.runtime, files (readFile,writeFile,listDir), automations.RuntimeClient:chatStream, store document reads.ChatClient/ChatStream: headless chat with typed events, no React.streamChat,createSession,listSessions,getSessionHistory(chat API),streamSSE/parseSSELine(SSE utilities): the lowest-level pieces, used by the clients above.
Not yet wired
A few exported hooks are shape-stable contracts whose backing endpoints are not on the runtime yet; they surface a NOT_IMPLEMENTED SdkError through their normal error channel instead of loading forever: useAuth, useTeamMembers, useMemory, and useIntentQuery (there is no read-only intent query in the replay model; read results with useStoreQuery). Treat them as reserved.