A framework-free <ag-ui-chat> Web Component over the AG-UI
protocol. Drop it into any page — SPA or MPA, any framework or none — point it at an AG-UI
endpoint, and you get a streaming chat sidebar that can call tools you register in the browser.
It wraps @ag-ui/client's HttpAgent and ships:
- A Custom Element with a self-contained Shadow DOM chat UI (header, scrolling transcript, input row), themeable via CSS custom properties.
- A pluggable client-side tool registry —
registerTool({ name, description, parameters, handler }); every registered tool is added to each run'sRunAgentInput.tools. - Generic DOM-driver primitives (
fillField,clickElement,setControlValue) and animation primitives (typeInto,highlightThenClick, …) so the agent can drive the page at human-readable speed. - An inline confirmation card that intercepts tool calls needing confirmation (those whose
JSON Schema carries
x-destructive: true, or a per-callconfirmPredicate) before the handler runs — rendered right in the transcript, never a modal overlay. - Markdown + HTML rendering of assistant replies (sanitized
marked+ DOMPurify), with themes, density/placement presets, incoming-text animations, tool-call display modes, an animated thinking indicator, and an opt-in skills palette (prompt chips +/-commands). - A new-chat button and a collapse toggle built into the header.
- An MPA durability story: a durable conversation store, a stable thread id, and a resumable
run loop that survives full page reloads (
x-navigates+navigationResult). - Host seams for SPAs: a route map, an auto-injected page map, state hooks, and an optional
navigate()callback.
No framework, no Django, no admin specifics live here. Downstream consumers (e.g.
django-admin-agent) register their own tool handlers on top via the pluggable registry.
- Install
- Quickstart
- Core concepts
- New chat and collapse
- Tool-call display modes
- Markdown rendering
- Run notices: compaction and agent skills
- Skills: prompt chips and slash palette
- MPA durability: surviving full page reloads
- Host seams: the SPA story
- Public API surface
- Theming, density, and placement
- Internationalization (i18n)
- Building the bundle
- Compatibility
npm install @artooi/ag-ui-web-componentThe package ships two builds (see package.json exports):
| Entry | What it is | When to use |
|---|---|---|
@artooi/ag-ui-web-component |
ESM library build; @ag-ui/* stay external |
You bundle the app yourself (Vite, webpack, esbuild) and want to dedupe @ag-ui/*. |
@artooi/ag-ui-web-component/bundle |
ESM bundle with @ag-ui/* inlined, minified |
Drop in via a single <script type="module"> with no build step. |
The /bundle entry inlines every dependency into one self-contained ESM file
(dist/ag-ui-web-component.bundle.js). This is the artefact intended for vendoring: a host
that can't (or won't) run a JS build — for example a Django app — copies the built bundle into its
static/ directory and serves it directly. django-admin-agent re-vendors a pinned built bundle
on every release. For SPA hosts that already have a bundler, import the bare package name instead
so @ag-ui/client / @ag-ui/core are deduped against the rest of your app.
Drop the element into your page and register the tools the agent may call:
<script type="module">
import {
defineAgUiChat,
fillField,
clickElement,
X_DESTRUCTIVE_KEY,
} from "@artooi/ag-ui-web-component";
// Register the <ag-ui-chat> Custom Element. Idempotent and SSR-safe — it is an
// explicit call, not an import side effect, so the package stays tree-shakeable.
defineAgUiChat();
const chat = document.querySelector("ag-ui-chat");
// Extra request headers (e.g. CSRF) sent to the AG-UI endpoint.
chat.headers = { "X-CSRFToken": getCsrfToken() };
// A non-destructive tool: fills a text field with a typing animation.
chat.registerTool({
name: "fill_field",
description: "Fill a text input by id with a value.",
parameters: {
type: "object",
properties: { field: { type: "string" }, value: { type: "string" } },
required: ["field", "value"],
},
handler: async ({ field, value }) => {
await fillField(document.getElementById(field), String(value));
return "ok";
},
});
// A destructive tool: x-destructive at the JSON-Schema root gates it behind
// the inline confirmation card before the handler runs.
chat.registerTool({
name: "save_article",
description: "Save the article. Destructive — asks for confirmation.",
parameters: { type: "object", properties: {}, [X_DESTRUCTIVE_KEY]: true },
handler: async () => {
await clickElement(document.getElementById("save"));
return "saved";
},
});
</script>
<ag-ui-chat endpoint="/agent/" title-text="Assistant"></ag-ui-chat>That's the whole integration: an endpoint attribute pointing at your AG-UI server, optional
headers, and the tools you want the agent to be able to invoke in the browser.
Attributes (set in HTML; the CSS-only ones are styling presets with no JS API):
| Attribute | Property | Notes |
|---|---|---|
endpoint |
endpoint |
The AG-UI endpoint URL. Required to send. Reflecting getter + setter. |
title-text |
— | Header label; defaults to "Assistant". The only observed attribute (live-updates the header). |
data-tool-display |
toolDisplay |
Tool-call card detail: inline / minimal / compact / full (default full). |
data-text-animation |
— | Incoming-text reveal: none (default) / fade / word. |
data-prompt-chips |
— | "true" to surface skills as chips. |
data-slash-commands |
— | "true" to enable the /-command palette. |
data-skills |
— | Inline JSON skill catalog. |
data-skills-url |
— | URL of a JSON skill catalog (fetched with headers). |
data-tools-url |
— | URL of a server tool-label catalog ([{ name, summary, description? }]), fetched with headers; labels tool-call cards for server-side tools. |
data-threads-url |
— | URL of a server thread index (django-ag-ui's ThreadsView); enables durable, cross-device chat history. |
data-runs-url |
— | URL of a server run index (django-ag-ui's RunsView); reveals the header's ⭯ Continue a run panel. See Resuming a run. |
data-attachments-url |
— | URL of the file-upload endpoint (django-ag-ui's AttachmentsView); reveals the composer's 📎 picker + drag-and-drop. |
data-attachment-accept |
— | <input accept> list for client-side type filtering (e.g. image/*,.pdf). The server stays authoritative. |
data-attachment-max-bytes |
— | Client-side upload size cap in bytes (default 10 MiB; 0 disables). The server stays authoritative. |
data-transcribe-url |
— | URL of the voice-transcription endpoint (django-ag-ui's TranscribeView); reveals the composer's 🎤 mic button. See Voice input. |
data-theme-toggle |
— | Boolean: show a built-in header light⇄dark toggle (persists per tab). Off by default. See Theme toggle. |
data-strings |
strings |
Partial JSON override of the UI string table (localization). The property wins key-by-key over the attribute; see Internationalization. |
data-icon-url |
— | Header (and sidebar-rail) icon image URL. A slotted slot="icon" wins; see Header & launcher icon. |
data-page-actions |
— | Opt-in built-in page-action tools: a comma list of scroll / drag (e.g. "scroll,drag"). See Page-action tools. |
data-side |
— | CSS-only, for placement="sidebar": which edge it docks to — right (default) / left. |
data-answer-well |
— | CSS-only boolean: box each assistant turn (its text, tool cards, and thinking) in one bordered "well". Off by default. See The answer well. |
collapsed |
collapsed |
Reflected boolean; collapses the widget (to a rail under placement="sidebar"). Persisted per-tab in sessionStorage. |
theme |
— | CSS-only: light (default) / dark / auto / code. |
density |
— | CSS-only: comfortable (default) / compact. |
placement |
— | CSS-only: floating (default) / bottom-left / side / sidebar / full / page / embedded. |
Properties (JS only, not attributes): headers, allowImages, autoConfirm,
confirmPredicate, askUser, agentFactory, getTools, getContext, routeMap, navigate,
getPageMap, autoInjectPageMap, conversationStore, uploadHandler, transcribeHandler,
navigationResult, skillContext, toolSummaries, strings, resolvePageTarget, plus the
mirrors endpoint / toolDisplay / collapsed.
allowImages (default false) re-enables <img> in rendered assistant markdown.
It is off by default because a model-controlled image URL is fetched by the browser
with no user interaction — a zero-click exfiltration channel for prompt-injected
page data. Enable only when the content source is trusted.
toolSummaries is a Record<string, string> mapping tool name → a friendly card
label, used when a tool has no x-summary in its own schema. Built-in and client tools
should carry x-summary directly; this map is the seam for server-side tools (drf-mcp,
the django-ag-ui @tool registry), whose schema never reaches the browser — e.g.
chat.toolSummaries = { list_projects: "Search projects" }. Or point
data-tools-url at a server catalog endpoint (django-ag-ui's tools/) and the
labels are fetched automatically — per card, x-summary → an explicit
toolSummaries entry → the fetched catalog → the raw name.
Properties (selected): sharedState — AG-UI shared state (documented under Tools & state).
Methods: registerTool, registerPageState, setSkills, appendMessage, newChat,
setCollapsed, toggleCollapsed.
A self-contained live playground lives in demo/ — run make demo to serve it against a
mock AG-UI server.
<ag-ui-chat> is the view; AgUiClient is the orchestration layer over
an AG-UI AbstractAgent. On the first send the element builds a client (via the overridable
agentFactory, which defaults to createHttpAgent). Each turn:
- The user message is appended and the agent runs once.
- AG-UI subscriber events are translated into the element's handlers — streaming text deltas
render into a bubble; each
TOOL_CALL_ENDbecomes a tool-call card. - Any frontend tool calls collected during the run are executed locally, their results are
appended as
toolmessages, and the agent is re-run with the results. - This repeats until the agent stops calling frontend tools, bounded by
MAX_TOOL_ROUNDS.
Tool calls the client doesn't own (server-side tools the server already executed) are left alone —
the loop doesn't re-run them, but their streamed TOOL_CALL_RESULT is rendered into the tool-call
card (honouring data-tool-display), so server-side output is visible too. The current tool catalog
and context are read fresh on every run (getTools() / getContext()), so they always reflect
the current page state.
While a run is in flight the Send button becomes Stop (same button, label/aria-label swap,
data-state="running" for styling); clicking it — or pressing Escape in the composer (when
the skills palette is closed; the palette owns Escape while open) — calls AgUiClient.cancel().
AG-UI has no server-side cancel route: cancelling aborts the streaming request
(abortRun()), and the server observes the disconnect. On cancel:
- Partial assistant text already streamed stays in the transcript and is persisted via
onPersist, so a reload shows the truncated exchange. A muted "⏹ Stopped" note is appended (.stopped-note) — a deliberate stop is not an error, so no⚠️ bubble. - The run loop stops: tool calls collected before the abort are not executed, and no further round starts. A frontend tool handler already running completes, but its result doesn't trigger a re-run.
- An open confirmation card is declined (
data-resolved="declined") — cancelling the run answers the pending question. Likewise an open approval card is denied and an open question card (ask_user) resolves with an empty answer. - The new
onCancelled()handler fires instead ofonError();onSettled()still follows (the terminal-rest guarantee), returning the button to Send.
cancel() with no run in flight is a safe no-op. newChat() cancels any in-flight run before
discarding the client.
A tool is a ClientTool: { name, description, parameters, handler }, where parameters is a
JSON Schema and handler receives the parsed args and returns a value that is JSON-serialised
into the tool-result message. Register them on the element:
chat.registerTool({
name: "search_products",
description: "Search the catalog.",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
handler: async ({ query }) => await api.search(query),
});Names must be unique (registering a duplicate throws). Each <ag-ui-chat> element owns its own
registry, AG-UI client, and Shadow DOM, so multiple instances on one page never interfere —
there is no module-level shared state anywhere in the package.
When a tool call needs confirmation, the element appends an inline confirmation card (a
<div class="confirm">) to the transcript via
requestConfirmation — it is not a modal overlay. The card reads
naturally after the assistant's explanation, never steals focus from the page, and stays in the
transcript as a resolved record after the decision:
- Confirm → the handler runs and the result is posted back.
- Cancel → a
"User declined the action."result is posted; the agent acknowledges on its next turn.
Whether a call is gated is decided in this order:
- If
chat.autoConfirm === true, the call never prompts (an "autopilot" toggle). - Else if
chat.confirmPredicateis set, its boolean return is authoritative — given the tool name + parsed args it decides per-call (so one tool can be instant for some args and confirmed for others, which a static flag can't express). - Else the element falls back to
isDestructive(parameters), which reads thex-destructiveJSON-Schema flag.
AG-UI has no built-in risk flag, so destructiveness is carried as a JSON-Schema extension at the
schema root: parameters["x-destructive"] = true (use the exported X_DESTRUCTIVE_KEY
constant). There is no parallel metadata channel and no name heuristic — destructiveness is exactly
the x-destructive flag (or confirmPredicate). The registry forwards the flag verbatim to
RunAgentInput.tools.
If the schema carries an x-confirm string (use X_CONFIRM_KEY), the card shows it as the prompt;
otherwise it falls back to a generic Run "<tool>"?.
// Per-call: confirm a delete only when it would remove more than one row.
chat.confirmPredicate = (name, args) =>
name === "delete_rows" && Array.isArray(args.ids) && args.ids.length > 1;
// Prompt text via x-confirm:
chat.registerTool({
name: "activate_project",
description: "Activate the current project.",
parameters: { type: "object", properties: {}, [X_DESTRUCTIVE_KEY]: true, [X_CONFIRM_KEY]: "Activate this project?" },
handler: async () => await api.activate(),
});The confirmation card gates client-registered tools before they run. A server-side tool runs on the server, so the browser can't intercept it the same way — that is what the approval card below is for.
When the server gates a destructive tool (e.g. django-ag-ui's ToolGuard), the tool defers
instead of executing and the run finishes on an AG-UI interrupt. The element then appends an
inline approval card (a <div class="approval">) via
requestApproval, next to the pending tool-call card:
- Approve → the run resumes and the server runs the tool; its result streams back into the same card.
- Deny → the run resumes carrying a
cancelledanswer, so the model learns the tool was declined; the pending card settles as declined.
This uses the AG-UI protocol's own interrupt/resume mechanism (RunAgentInput.resume[]) — the
wire stays vanilla AG-UI. A Stop while an approval card is open denies every open card and
cancels the run. No configuration is needed on the client; the gate is enabled server-side.
Like the question card, the approval card is customizable at three levels: text (strings:
approveAction / approvalPrompt / approve / deny), CSS (::part(): approval,
approval-body, approval-actions, approval-button, approval-approve, approval-deny), and
full replacement via chat.approvalRenderer — given the request (message + toolName) and
a Stop AbortSignal, render your own UI and resolve true/false:
chat.approvalRenderer = (request, { signal }) =>
myConfirmDialog(request.message ?? `Run ${request.toolName}?`, { signal });Set chat.askUser = true to offer the agent a built-in ask_user frontend tool. When the agent
calls it, the element renders an inline question card (a <div class="question">) via
requestQuestion and returns the user's answer as the tool result:
chat.askUser = true; // opt in; off by default so the tool catalog is unchanged otherwiseask_user(question, options?, allow_custom?) renders options as radio buttons, adds a free-text
field when allow_custom is set (or when no options are given), and feeds the chosen or typed
answer back through the normal frontend-tool path — no new protocol. A Stop dismisses an open
question with an empty answer.
The question card is fully customizable at three levels:
- Text — every label is a
stringskey:askUserAction(the card'saria-label),otherOption,answerPlaceholder,submit. - CSS — every element exposes a
::part():question,question-body,question-options,question-choice,question-radio,question-input,question-actions,question-button(plus the--ag-ui-*theme variables). No shadow piercing. - Full replacement — set
chat.askUserRendererto own the entire UI. Given the parsed request and anAbortSignal(fired on Stop), render anything — a native modal, a framework component — and resolve with the answer (empty string = no answer). The built-in card is bypassed entirely.
// Level 1+2: restyle the built-in card.
chat.strings = { submit: "Answer", answerPlaceholder: "Type here…" };
// ag-ui-chat::part(question) { border-radius: 0; }
// Level 3: replace the card with your own UI.
chat.askUserRenderer = (request, { signal }) =>
myModal.ask(request.question, request.options, { allowCustom: request.allowCustom, signal });So the agent can visibly drive the host page, the package ships generic, framework-free
primitives. The animation primitives (animations.ts) operate at
human-readable speed (configurable; pass small/zero durations in tests):
typeInto(el, value, { charDelayMs })— clears and types a value character by character, firinginput/changeevents as a real user would.highlightThenClick(el, { highlightMs })/pressThenClick(el, options)— outline/press an element, pause, then click.selectOption(el, value)/toggleControl(el, checked)— animate a<select>/ checkbox.scrollIntoCenterView(el)/focusWithFlash(el, { flashMs }).prefersReducedMotion()— honoured throughout so animations collapse to instant when the user asks for reduced motion.
The DOM-driver primitives (dom_driver.ts) compose those into the
operations a tool handler typically wants:
fillField(el, value, options)— scroll to, focus-flash, and type into a text field.clickElement(el, options)/pressButton(el, options)— scroll to, highlight/press, and click.selectControl(el, value)/toggleCheckbox(el, checked)— animate a<select>/ checkbox.setControlValue(el, value)— set a<select>or checkbox without animation, dispatchinginput/change.
The native-setter helpers (native_setter.ts) — setNativeValue /
setNativeChecked — set a control through its native prototype setter so React-controlled inputs
register the change.
Each takes an element the caller has already located; host packages wrap them with
environment-aware lookups (e.g. "find #id_<name>, then fillField").
Two built-in client tools let the agent perform common page interactions without every host
re-implementing them. They are opt-in via data-page-actions — a comma list of the tokens you
want — so you control the agent's interaction surface:
<ag-ui-chat endpoint="/agent/" data-page-actions="scroll,drag"></ag-ui-chat>scroll_to— scroll a target into view.targetis"top","bottom", or a CSS selector / page-map element id. Read-only (no confirmation).drag_and_drop— drag thefromelement onto thetoelement (selectors / page-map ids), firing the standard HTML5 drag sequence (dragstart→dragenter/dragover/drop→dragend) so the page's own drop handler reacts. Useful for reordering sortable lists.
Targets resolve through the overridable resolvePageTarget property — (target) => HTMLElement | null, defaulting to document.querySelector. A host with a page map overrides it to map its own
element ids (the same way the DOM-driver primitives are wrapped with environment-aware lookups):
chat.resolvePageTarget = (id) => myPageMap.elementFor(id);Destructiveness. Page actions are not stamped x-destructive — a drag rearranges transient
state, and the durable change happens at the page's explicit commit (a Save), which stays in the
user's hands. If your page persists on drop (a kanban board firing a PATCH from the drop
handler), gate drag_and_drop with confirmPredicate
— or don't enable it. A target that resolves to nothing returns a clean, model-readable tool error.
The header carries two built-in buttons: a new-chat (✚) button and a collapse (—) toggle. The matching JS API:
newChat()— clears the transcript and the persisted history, drops the in-memory run state, and mints a new thread id.setCollapsed(collapsed)/toggleCollapsed()— collapse or expand the widget. The state is reflected as the booleancollapsedattribute/property and persisted per-tab insessionStorage, so it survives a reload.
Each change emits an ag-ui-toggle event (the TOGGLE_EVENT constant) with
detail: { collapsed: boolean } (typed ToggleDetail), so a host can mirror the state in its own
chrome — or hide the built-in toggle and drive the collapsed attribute itself.
chat.newChat();
chat.toggleCollapsed();
chat.addEventListener("ag-ui-toggle", (e) => console.log(e.detail.collapsed));How much a tool-call card shows is set via the data-tool-display attribute (or toolDisplay
property), one of inline / minimal / compact / full (default full):
inline— the lightest mode: a single status row (icon + summary, no card chrome) with the result behind its own toggle. Reads as one line of the answer — pairs with the answer well.minimal— tool name + status pill only.compact— name + status, with args and result behind a single collapsed "Details" toggle.full— args inline, result behind its own toggle (the original behaviour).
If a tool's schema carries an x-summary string (use X_SUMMARY_KEY), the card shows it on the
label instead of the raw tool name.
Every card leads with a status icon drawn entirely in CSS — a spinning ring while the call
runs, then a check / cross / slash on success / error / decline. Re-theme it via custom
properties (or the tool-card-icon part): --ag-ui-tool-icon-done, --ag-ui-tool-icon-error,
--ag-ui-tool-icon-declined (quoted-string glyphs) and --ag-ui-tool-spin-duration (spinner
speed; the spin respects prefers-reduced-motion).
<ag-ui-chat endpoint="/agent/" data-tool-display="compact"></ag-ui-chat>Assistant bubbles render sanitized markdown/HTML via marked
(GitHub-flavoured, single-newline line breaks) piped through
DOMPurify. User messages stay literal text. The
allowlist permits emphasis, code, lists, quotes, headings, links, tables, and images (img); links
are hardened with target="_blank" rel="noopener noreferrer"; iframe/style/scripting are
excluded. The exported helper renderMarkdown(text) does this standalone. marked and dompurify
are runtime dependencies.
An animated 3-dot "thinking" indicator (role="status", with an aria-label) appears before the
first token and between tool rounds, honouring prefers-reduced-motion. It has no public API.
The data-text-animation attribute controls how a fully-received assistant message reveals:
none (default) / fade (a CSS fade) / word (JS word-by-word via the internal wrapWords
reveal). It honours prefers-reduced-motion (collapsing to instant).
<ag-ui-chat endpoint="/agent/" data-text-animation="word"></ag-ui-chat>Some things a run does are neither text nor a tool the user asked for — the server condensed
earlier turns to fit the context window, or the model pulled in an agent skill. Those render as
run notices: a muted one-line annotation inline in the transcript, styleable via the
run-notice, run-notice-icon and run-notice-text parts.
Two different things are called "skills". The prompt chips and slash palette below are a human affordance — prompts the user launches. An agent skill is a folder of instructions the model chooses to load mid-run. Only the second produces a run notice.
Neither notice needs configuration here — both appear when the server is set up to produce them.
Compaction. django-ag-ui emits a standard AG-UI ACTIVITY_SNAPSHOT with
activityType: "compaction" when a compaction capability trimmed the history; the notice reports
how many messages went. Server side, that means wrapping the capability in CompactionObserver —
see django-ag-ui's compaction guide. Activity
events of any other type pass through untouched, so another producer on that channel is not
mistaken for a compaction.
Agent skills. There is no dedicated event for these: loading a deferred capability is an
ordinary load_capability tool call, which is what reaches the client. The component recognises
it, renders Using skill <id>, and suppresses the raw tool card that would otherwise appear
beside it — on the live stream and on restored history alike, so a reload shows the same
transcript. A load_capability call with no usable id falls back to a normal tool card rather
than being dropped, since it is still real activity.
Both strings are overridable like every other — historyCompacted (token {count}) and
usingSkill (token {name}).
Skills are pre-defined prompts the user can launch from a chip or the /-command palette. They are
opt-in via two attributes:
<ag-ui-chat endpoint="/agent/" data-prompt-chips="true" data-slash-commands="true"></ag-ui-chat>A Skill is { name, title, description?, prompt, sendImmediately?, chip? }. Skills are merged
from three sources — backend → embed → client (later wins by name):
data-skills-url— a JSON endpoint, fetched with the element'sheaders.data-skills— an inline JSON catalog.setSkills(skills)— set the client catalog from JS.
chat.setSkills([
{ name: "summarize", title: "Summarize page", prompt: "Summarize {title}.", chip: true },
]);A skill prompt may contain {placeholder} tokens; the skillContext property
(() => Record<string, unknown>) supplies the values, filled in before send. A missing placeholder
blocks the send and shows a hint instead.
chat.skillContext = () => ({ title: document.title });In a multi-page app, a tool that navigates reloads the whole page and destroys the in-memory run loop. The package keeps the conversation continuous across that boundary with three generic mechanisms.
1. Thread identity. AG-UI's thread_id is the conversation key. It is generated once and
persisted (so the element reattaches after a reload) by the
ClientConversationStore.
2. Durable conversation. A pluggable ClientConversationStore holds the message list. The
default SessionStorageStore keeps everything per-tab in
sessionStorage, so the chat survives full page reloads and clears on tab close. loadMessages
is async-friendly, so a host can inject a server-backed store (e.g. one that rehydrates from a
history endpoint) for cross-tab/device durability:
chat.conversationStore = new MyServerBackedStore();On mount the element rehydrates the transcript from the store, so the chat looks continuous —
including tool-call cards and their results (reconstructed from the persisted toolCalls and tool
messages), not just the text turns.
3. Resumable loop (x-navigates + navigationResult). A tool whose schema carries
x-navigates: true (use X_NAVIGATES_KEY; read back by isNavigates)
triggers a full reload. Before the handler navigates, the element writes a checkpoint
({ toolCallId }) to the store. On the next page mount it:
- restores the transcript,
- completes the dangling navigating tool call by supplying a result built from the landed page
via the overridable
navigationResult(checkpoint)callback (defaults to{ navigated: true, url }; a host can return a page snapshot or post-reload validation errors instead), - and resumes the run loop from there.
The MPA round-trip becomes a clean observation point instead of a dropped conversation.
<ag-ui-chat> is a generic embedding kit; these typed seams let a host feed the agent richer
context up front so it explores less. All are framework-free and admin-agnostic.
routeMap: RouteMap — a manifest of navigable routes ({ id, path, title?, group?, description? }). When set, the element exposes two built-in tools so the agent navigates by intent
rather than by exploring:
list_routes— read-only; lists the routes.navigate_to_route(route_id, params?)— resolves the id to a path and navigates.
A route path may contain :param segments — e.g. /projects/:id/users/:userId/.
navigate_to_route substitutes the path params (URL-encoded) and sends any leftover params as a
query string; a missing or empty required path param throws. The resolved shape is the exported
RouteWithParams type.
chat.routeMap = [
{ id: "users", path: "/users", title: "Users", description: "Manage user accounts" },
{ id: "billing", path: "/billing", title: "Billing" },
{ id: "user-detail", path: "/projects/:id/users/:userId/", title: "User detail" },
];getPageMap(): PageMap — a per-run provider returning the current page's compact actionable
surface (field names/types/labels, button labels+handles — not values). It is auto-injected into
each run's context as a page_map entry (toggle with autoInjectPageMap). Recomputed every run
so it reflects the page the agent is currently looking at:
chat.getPageMap = () => ({ fields: introspectForm(), buttons: visibleButtons() });registerPageState({ name, read, write?, schema? }) — ergonomic sugar over registerTool for
SPA app state (Redux/Zustand/signals). It auto-generates a read_<name> (read-only) tool and, when
write is supplied, a set_<name> tool stamped x-destructive:
chat.registerPageState({
name: "cart",
read: () => store.getState().cart,
write: ({ items }) => store.dispatch(setCart(items)),
schema: { type: "object", properties: { items: { type: "array" } } },
});This is not AG-UI shared state — that is sharedState, below. registerPageState generates two
ordinary client tools; the agent reads or writes your store by calling a tool. The method was
called registerStateHook through 0.12, a name that read as protocol state sync; the old spelling
still works and is deprecated.
sharedState (property) — AG-UI shared state: the protocol's own state channel, sent as
RunAgentInput.state on every run and replaced in place when the server streams STATE_SNAPSHOT /
STATE_DELTA. Assign to seed it, listen for ag-ui-state to react:
chat.sharedState = { document: "" };
chat.addEventListener("ag-ui-state", (e) => {
editor.value = e.detail.state.document; // the agent rewrote it
});Server-side, a tool mutates ctx.deps.state and returns the snapshot as ToolReturn metadata —
pydantic-ai does not emit deltas for you:
@tool(registry)
async def write_document(ctx: RunContext[AgentDeps], body: str) -> ToolReturn:
"""Replace the shared document."""
ctx.deps.state = {**(ctx.deps.state or {}), "document": body}
return ToolReturn(
return_value="written",
metadata=[StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=ctx.deps.state)],
)Use this when the agent and the page are editing the same object (a document, a form, a
canvas). Use registerPageState when the agent should ask for a value or request a change —
the tool call is visible in the transcript and can be gated by a confirmation card, which state
events cannot.
navigate(path): void (optional) — a host routing callback. This single seam is what
distinguishes an SPA from an MPA. When set, navigate_to_route routes client-side (no reload) and
the in-memory run loop simply continues — the whole resumable-loop / checkpoint machinery is
bypassed. When unset, navigation falls back to window.location and the MPA reload model above
applies.
chat.navigate = (path) => router.push(path); // SPA: in-page, no reload
// leave unset for an MPA: window.location + checkpoint/resumeRoute map + navigate() and the reload model are the same feature seen from two ends.
When the server persists run checkpoints (django-ag-ui's step_store), a run
that stopped part-way can be continued rather than restarted. Point the
component at the run index and a ⭯ button appears in the header:
<ag-ui-chat endpoint="/agent/" data-runs-url="/agent/runs/"></ag-ui-chat>The panel lists runs the server marked continuable — those with a saved snapshot to seed from. A run that never reached a provider-valid boundary has none, so it isn't offered: resuming it would start from nothing. Each row shows when the run started (the id is on hover, for correlating with server logs) and marks a run that branched from another, so a fork doesn't read as a duplicate of its parent.
Type the next turn in the composer, then pick a row:
- Resume — continue that run.
- Fork — branch it, leaving the original untouched.
Both send to the matching server endpoint and stream into the same transcript.
data-runs-url is the only thing to configure. resume/<id>/ and fork/<id>/
are siblings of the index — django-ag-ui mounts all three under one prefix
whenever a step store is set — so they're derived, and there's no way to end up
with a half-configured set.
Those endpoints expect a request carrying a fresh run id and only the new turn: the server supplies the prior turns from the snapshot, so re-sending them would duplicate the conversation.
The component satisfies that structurally rather than by remembering a rule. A continuation runs on its own short-lived agent, built pointing at the resume endpoint and seeded with no history — so "only the new turn" is the only thing it can send, and the fresh run id comes free because a new agent mints one. Your main agent's history is never touched.
A resumed run is a normal run in every other respect: frontend tools execute,
approval interrupts render their card, and headers are re-read per request so
a rotated CSRF token or JWT still reaches the endpoint.
If the index can't be reached, the panel shows its empty state rather than an error — a history affordance that fails is empty, not broken.
Set data-attachments-url (django-ag-ui's AttachmentsView) to let the user attach files
to a message. A 📎 button and drag-and-drop appear on the composer; each picked file uploads
out-of-band (multipart, with the element's headers) and shows a chip in a pending tray —
uploading (with a progress bar) → ready, or error with a retry. On send, the ready files'
refs ride on the user bubble as read-only chips and the agent reads their contents
server-side via the read_attachment tool. The wire stays vanilla AG-UI: only lightweight refs
({ id, name, mime, size }) travel, never the bytes.
<ag-ui-chat
endpoint="/agent/"
data-attachments-url="/agent/attachments/"
data-attachment-accept="image/*,application/pdf,text/plain"
data-attachment-max-bytes="10485760"
></ag-ui-chat>Client-side accept / size checks are an instant-feedback nicety — the server is
authoritative. Refs persist on the message, so a restored conversation re-renders its chips.
Without the attribute the affordance stays hidden and the chat is text-only.
Swapping the upload transport. The built-in multipart POST is just the default
uploadHandler. Set your own to use a different transport — a resumable
tus-js-client adapter, direct-to-S3 multipart, etc.
— without touching the tray, the chips, or the AG-UI wire (refs are transport-agnostic). The
handler is (file, onProgress) => Promise<AttachmentRef>; when set, the 📎 affordance appears
even with no data-attachments-url, and your handler owns its own endpoint and headers:
import { Upload } from "tus-js-client";
chat.uploadHandler = (file, onProgress) =>
new Promise((resolve, reject) => {
const up = new Upload(file, {
endpoint: "/tus/",
headers: chat.headers,
onProgress: (sent, total) => onProgress(sent / total),
onError: reject,
onSuccess: () =>
resolve({ id: up.url.split("/").pop(), name: file.name, mime: file.type, size: file.size }),
});
up.start();
});The server side is the matching half: the agent reads bytes by ref id, so point the
read_attachment store at wherever your transport persisted them (django-ag-ui's
AttachmentStore is the seam). The refs themselves never change shape.
Everything below is re-exported from the package root (src/index.ts) — the only
re-export point. Internal modules import from leaf paths.
| Export | Kind | Summary |
|---|---|---|
AgUiChat |
class | The <ag-ui-chat> Custom Element. |
defineAgUiChat() |
function | Idempotently register the element. |
MessageRole |
type | Role of a rendered chat message. |
SubmitDetail |
type | detail shape of the submit event. |
ToggleDetail |
type | detail shape of the ag-ui-toggle event ({ collapsed }). |
| Export | Kind | Summary |
|---|---|---|
AgUiClient |
class | Orchestration layer over an AG-UI AbstractAgent. |
AgUiClientConfig / AgUiClientHandlers / AgUiRunInputs |
type | Client config, lifecycle handlers, per-run input providers. |
AgUiToolCall / ToolExecution / ExecuteTool |
type | Tool-call shape, execution result, executor signature. |
ConnectionLostError |
class | Raised (→ onError) when a run's stream closes with no terminal AG-UI event. |
createHttpAgent(options) |
function | Default agent factory (wraps HttpAgent). |
AgentFactory / HttpAgentOptions |
type | Factory signature and its options. |
| Export | Kind | Summary |
|---|---|---|
ClientToolRegistry |
class | Per-element tool registry. |
ClientTool |
type | A frontend tool declaration. |
isDestructive(parameters) |
function | Read the x-destructive flag. |
isNavigates(parameters) |
function | Read the x-navigates flag. |
createPageActionTools(enabled, resolveTarget) |
function | Build the opt-in scroll_to / drag_and_drop tools. |
PAGE_ACTIONS |
const | The page-action opt-in tokens (scroll / drag). |
ResolvePageTarget |
type | `(target) => HTMLElement |
X_DESTRUCTIVE_KEY / X_NAVIGATES_KEY |
const | The JSON-Schema extension keys. |
| Export | Kind | Summary |
|---|---|---|
createRouteTools(...) |
function | Build the built-in route.* tools. |
Route / RouteMap |
type | Navigable-route shapes. |
RouteWithParams |
type | A route resolved with :param path segments + leftover query params. |
createPageMapContext(...) |
function | Build the per-run page_map context entry. |
PageMap |
type | The compact page-surface shape. |
createPageStateTools(binding) |
function | Build read_<name> / set_<name> tools. |
PageState |
type | A page-state binding declaration. |
Skill |
type | A launchable prompt (chip / /-command). |
| Export | Kind | Summary |
|---|---|---|
SessionStorageStore |
class | Default per-tab conversation store. |
RemoteConversationStore |
class | Server-backed store over a data-threads-url endpoint. |
ClientConversationStore |
type | The persistence seam. |
ThreadMeta |
type | A thread-drawer row ({ threadId, title, updatedAt, preview }). |
NavigationCheckpoint |
type | The pre-reload checkpoint marker. |
RunIndex |
class | Reads a data-runs-url run index and derives its resume / fork endpoints. |
RunRow |
type | One run index row ({ run_id, thread_id, parent_run_id, started_at, continuable }). |
CheckpointMenu |
class | The Continue a run panel. |
CheckpointVerb |
type | `"resume" |
| Export | Kind | Summary |
|---|---|---|
uploadAttachment(file, options) |
function | The built-in upload (multipart, progress) → AttachmentRef. |
UploadOptions |
type | { url, headers?, onProgress?, signal? }. |
UploadHandler |
type | (file, onProgress) => Promise<AttachmentRef> — the uploadHandler swap seam (TUS / S3). |
AttachmentRef |
type | The durable upload ref ({ id, name, mime, size, url? }). |
messageAttachments(message) |
function | Read the refs a restored user message carries. |
| Export | Kind | Summary |
|---|---|---|
ToolCallCard |
class | A live tool-call card for the transcript. |
ToolCallStatus / SettledStatus / ToolDisplayMode |
type | Card lifecycle states + display mode. |
requestConfirmation(host, request, options?) |
function | Append the inline confirmation card to the transcript. |
ConfirmationRequest |
type | What the card displays. |
ConfirmationOptions |
type | { signal?, strings? } — abort resolves the card as declined; strings localizes it. |
UiStrings |
type | The flat table of every user-facing string. |
DEFAULT_UI_STRINGS |
const | The English defaults (the override floor). |
mergeUiStrings(overrides) |
function | Merge a partial override over the defaults. |
renderMarkdown(text) |
function | Render sanitized markdown/HTML (marked + DOMPurify). |
typeInto / highlightThenClick / pressThenClick / selectOption / toggleControl / scrollIntoCenterView / focusWithFlash / prefersReducedMotion |
function | Animation primitives. |
fillField / clickElement / pressButton / selectControl / setControlValue / toggleCheckbox |
function | DOM-driver primitives. |
setNativeValue / setNativeChecked |
function | Set a control via its native prototype setter (React-controlled inputs). |
TypeOptions / HighlightClickOptions / PressOptions / SelectOptions / ToggleOptions / FlashOptions / FillFieldOptions / TextLikeElement |
type | Primitive option shapes. |
| Export | Summary |
|---|---|
ELEMENT_TAG |
The registered tag name (ag-ui-chat). |
SUBMIT_EVENT |
The submit CustomEvent name. |
TOGGLE_EVENT |
The collapse-toggle CustomEvent name (ag-ui-toggle). |
MESSAGE_ROLE |
Message role constants. |
TOOL_CALL_STATUS |
Tool-call card status constants. |
TOOL_DISPLAY |
Tool-call display-mode constants (minimal / compact / full). |
X_CONFIRM_KEY |
JSON-Schema key carrying a confirmation prompt. |
X_SUMMARY_KEY |
JSON-Schema key carrying a short tool-card label. |
MAX_TOOL_ROUNDS |
Upper bound on tool-call → re-run rounds per send. |
VERSION |
The package version string. |
The chat shell is styled inside its Shadow DOM and exposes a large set of --ag-ui-* CSS custom
properties on :host (colors, status, surface, spacing, layout), so you theme it from outside
without piercing the shadow boundary. A few of the knobs:
ag-ui-chat {
--ag-ui-accent: #4f46e5;
--ag-ui-bg: #ffffff;
--ag-ui-fg: #1a1a2e;
--ag-ui-radius: 12px;
/* Layout */
--ag-ui-width: 380px;
--ag-ui-height: 560px;
--ag-ui-inset: auto 24px 24px auto;
--ag-ui-shadow: 0 12px 32px rgba(20, 20, 50, 0.18);
}For the common cases there are three CSS-reactive preset attributes (no JS API), so you don't have to hand-tune the variables:
theme—light(default) /dark/auto(follow the OS) /code.density—comfortable(default) /compact.placement—floating(default) /bottom-left/side/sidebar/full/page/embedded.embeddeddrops the fixed positioning and z-index so the widget sits in normal document flow;pageis a full-screen centred reading column.
<ag-ui-chat endpoint="/agent/" theme="dark" density="compact" placement="side"></ag-ui-chat>See src/ui/styles.ts for the full variable + preset list. The
demo/ live playground (node demo/mock-server.mjs) flips theme, density, placement,
text-animation, tool-display, and the answer well live from a single page, and demos the
streamed thoughts region, the 🎤 mic, and the header theme toggle.
For styling beyond the --ag-ui-* variables, every structural element exposes a part so you can
reach it from outside the Shadow DOM with ::part() — no shadow piercing. The part names are
public API (additions are non-breaking; renames are breaking):
ag-ui-chat::part(panel) { border-radius: 0; }
ag-ui-chat::part(header) { background: #111; }
ag-ui-chat::part(send) { text-transform: uppercase; }
ag-ui-chat::part(tool-card) { font-family: var(--my-mono); }Available parts: panel, header, title, icon, header-controls, header-button
(plus history-button / new-button / collapse-button / theme-toggle), messages,
answer (the per-turn group), thoughts (plus thoughts-toggle / thoughts-body /
thoughts-label), message
(plus message-user / message-assistant), empty, pending, stopped (the "⏹ Stopped" note),
tool-card
(plus tool-card-head / -icon / -name / -status / -args / -toggle / -result),
confirm (plus confirm-body /
-args / -actions / -button / -cancel / -confirm),
approval (plus approval-body / -actions / -button / -approve / -deny),
question (plus question-body / -options / -choice / -choice-text / -radio / -input /
-actions / -button), composer, input, send,
attach-button, voice-button,
the attachment chips — attachment-tray and attachment-chips (the read-only chips on sent
bubbles) with the shared chip parts attachment-chip (plus -icon / -name / -size / -bar /
-bar-fill / -retry / -remove),
the skills UI (skill-chips, skill-chip, skill-palette, skill-item, skill-item-title,
skill-item-desc, and the missing-placeholder skill-hint),
launcher, launcher-icon, and the drawer parts
(drawer, drawer-backdrop, drawer-panel, drawer-header, drawer-title, drawer-new,
drawer-list, drawer-empty, drawer-row, drawer-row-select, drawer-row-title,
drawer-row-time, drawer-row-preview, drawer-row-actions, drawer-row-rename,
drawer-row-delete, drawer-rename-input, drawer-confirm, drawer-confirm-label,
drawer-confirm-yes, drawer-confirm-no).
Coarse slots let you replace whole regions with your own markup (project light-DOM children
with a matching slot=):
| Slot | Where |
|---|---|
icon |
A header brand icon, before the title. |
header-actions |
Extra controls between the title and the built-in buttons. |
empty |
The empty-state shown before any message. |
footer |
Below the composer. |
launcher |
The collapsed sidebar rail's content. |
<ag-ui-chat endpoint="/agent/">
<img slot="icon" src="/logo.svg" alt="" />
<button slot="header-actions" onclick="openHelp()">?</button>
</ag-ui-chat>Give the header a brand icon with either the icon slot (any markup) or the data-icon-url
convenience attribute (an <img>); the slot wins when both are set, and with neither the header
stays icon-less. The same icon seam feeds the collapsed sidebar rail. Size it via
--ag-ui-icon-size (default 22px).
<ag-ui-chat endpoint="/agent/" data-icon-url="/logo.png"></ag-ui-chat>placement="sidebar" is a full-height docked panel that slides open/closed and collapses to a
slim icon rail (rather than the floating launcher). It docks right by default; data-side="left"
docks it left. Collapse state reuses the collapsed attribute (persisted per-tab), and the rail
carries aria-expanded. The slide honours prefers-reduced-motion.
<ag-ui-chat endpoint="/agent/" placement="sidebar" data-side="left"></ag-ui-chat>It overlays the page by default (no host-layout coupling). To make the host content reflow around
it instead, set --ag-ui-position: static and place the element in your own grid/flex layout.
placement="page" turns the widget into a full-screen chat page: a full-bleed background with
the conversation in a centred reading column (default ~820px, set via --ag-ui-content-max-width).
The assistant turn spans the column width while the user message stays a right-aligned pill. Unlike
full (edge-to-edge, left-aligned), it's the layout you want for a dedicated /chat route. Pairs
naturally with the answer well.
<ag-ui-chat endpoint="/agent/" placement="page" data-answer-well></ag-ui-chat>Each assistant turn renders inside one .answer group (part answer) that holds its streamed
text, tool cards, and pending indicator — so a turn that calls tools reads as a single answer
rather than a string of loose siblings. Add the boolean data-answer-well attribute to box that
group in a bordered, padded "well"; without it the layout is the flat stack as before. The well is
pure CSS and turn-scoped — no JS API — and themeable via --ag-ui-well-bg / --ag-ui-well-border
(and ::part(answer)).
<ag-ui-chat endpoint="/agent/" data-answer-well></ag-ui-chat>When a reasoning model streams its chain-of-thought (django-ag-ui forwards it as AG-UI reasoning
events — enable a thinking budget via MODEL_SETTINGS, see its docs), the element renders a muted,
collapsible thoughts region (part thoughts) at the top of the current answer group. It opens
while the model reasons and folds away on the answer's first token; the reader can reopen it. The
web component handles the REASONING_* event family (and the deprecated THINKING_*, which
@ag-ui/client maps onto it), so no client config is needed — the thoughts appear whenever the
server forwards reasoning.
Set data-transcribe-url (django-ag-ui's TranscribeView) to reveal a 🎤 mic button in the
composer (part voice-button). Click it to record via MediaRecorder, click again to stop — the
clip is POSTed to the endpoint and the returned transcript is dropped into the textarea. Swap the
transport with a custom transcribeHandler — (audio: Blob) => Promise<string> — to use a
different STT endpoint or a browser Web Speech adapter without touching the button; when set, the
mic appears even with no data-transcribe-url.
<ag-ui-chat endpoint="/agent/" data-transcribe-url="/agent/transcribe/"></ag-ui-chat>theme is a plain attribute you can set yourself, and a host can always drop its own switch into
slot="header-actions". For convenience, the boolean data-theme-toggle attribute adds a built-in
light⇄dark toggle to the header (part theme-toggle) that flips theme and persists the choice
per tab. Off by default so it never competes with a host-supplied control.
<ag-ui-chat endpoint="/agent/" data-theme-toggle></ag-ui-chat>Every user-facing string — labels, placeholders, aria-labels, and title tooltips — is read
from a flat UiStrings table, so a non-English host can translate the widget without forking it.
Override any subset; the rest fall back to the English defaults. Two equivalent seams:
// As a property (merged over the defaults):
chat.strings = { send: "Senden", inputPlaceholder: "Frag mich…", stop: "Stopp" };<!-- Or inline, as JSON (the property wins key-by-key when both are set): -->
<ag-ui-chat endpoint="/agent/" data-strings='{"send": "Senden", "inputPlaceholder": "Frag mich…"}'></ag-ui-chat>Set strings / data-strings before the element connects (they resolve on mount). A few keys
are templates carrying {token} placeholders the widget fills in — e.g. minutesAgo
("{n}m ago"), confirmRun ("Run “{tool}”?"), tooLarge ("Too large (max {size})"). Keep the
token verbatim when translating. The full key list and English defaults live in
src/ui/ui_strings.ts (exported as DEFAULT_UI_STRINGS); mergeUiStrings
is exported too if you want to compute a complete table yourself.
The build is driven by esbuild plus tsc for type declarations:
make build # node esbuild.config.mjs && tsc -p tsconfig.build.jsonThis produces, into dist/:
index.js— the ESM library build;@ag-ui/*are left external so npm consumers dedupe them.ag-ui-web-component.bundle.js— the vendored ESM bundle, every dependency inlined and minified, suitable for direct<script type="module">embedding.ag-ui-web-component.bundle.css— the extracted CSS sidecar.index.d.ts(+ source maps) — type declarations; emitted.jsimport specifiers are preserved so consumers resolve types without extra flags.
Other workflow targets (all identical in name to the sibling Python packages):
| Target | What it does |
|---|---|
make test |
Vitest with a 100% line + branch + function + statement coverage gate. |
make lint |
biome check . + tsc --noEmit. |
make format |
biome format --write .. |
make demo |
Build, then serve the live playground (demo/themes/index.html) on port 5173 via demo/mock-server.mjs. |
| Component | Floor | Tested |
|---|---|---|
| Node (tooling/tests only) | 22 | 22, 24 |
| Browsers (runtime target) | ES2022 / evergreen | Chrome / Firefox / Safari 17+ |
@ag-ui/client |
latest 0.x | — |
The shipped artefact targets evergreen browsers (Shadow DOM, Custom Elements v1, ES2022). Node is only the build/test runtime, not a runtime target.
MIT © Artur Veres