Chat widget
Embed the complete chat in React, Vue, Preact, or a plain web page.
Agentak has two levels of chat surface:
ChatPanel/AgentChatreceives aChatSessionand wires every available feature to it. Use this for Pi or another live agent.ChatView/Chatreceives messages, state, and callbacks directly. Use this when your application already owns the full chat state.
The framework wrappers render one host element, inject Agentak's design tokens, and mount the Preact surface inside it. Give the host a height.
#React
import { useEffect, useMemo } from "react";
import { ChatPanel } from "agentak/react";
import { createPiSession } from "agentak/pi";
export function Assistant() {
const session = useMemo(() => createPiSession({ history: true, page: true }), []);
useEffect(() => () => session.dispose(), [session]);
return <ChatPanel session={session} style={{ height: "600px" }} />;
}#Vue
<script setup lang="ts">
import { onBeforeUnmount } from "vue";
import { ChatPanel } from "agentak/vue";
import { createPiSession } from "agentak/pi";
const session = createPiSession();
onBeforeUnmount(() => session.dispose());
</script>
<template>
<ChatPanel :session="session" class="h-[600px]" />
</template>#Preact
import { render } from "preact";
import { ChatPanel } from "agentak/preact";
import { createPiSession } from "agentak/pi";
const session = createPiSession();
render(
<ChatPanel session={session} style={{ height: "600px" }} />,
document.querySelector("#app")!,
);
// Call session.dispose() when this app unmounts.#Plain JavaScript
import { mountChat } from "agentak";
import { createPiSession } from "agentak/pi";
const session = createPiSession();
const chat = mountChat("#chat", { session });
chat.update({ session, generateTitle: true });
// Later:
chat.unmount();
session.dispose();mountChat(target, props) accepts an element or a selector. It makes the target a flex
container, injects tokens unless tokens: false, and returns:
update(props)to apply new props without losing the live transcriptunmount()to clear the target
Neither method disposes the session.
#ChatPanel props
The React, Vue, and Preact wrappers share these behavior props:
| Prop | Type | Description |
|---|---|---|
session | ChatSession | Runs the conversation. Required. |
generateTitle | boolean | Uses one extra model request to name the conversation. |
autoCompact | boolean | Set to false to compact only when a person asks. On by default. |
tokens | boolean | Set to false when the host already declares Agentak tokens. |
actions | ChatAction[] | Adds host controls to the end of the bar under the composer. |
emptyItems | ChatEmptyItem[] | Adds content below the greeting before the first message. |
prompts | ChatPrompt[] | Adds starter messages under the composer of an empty chat. |
linkBase | string | Base URL for relative links when the chat discusses another document. |
autoFocus | boolean | Focuses the composer on mount on non-touch devices. |
class / className | string | Styles the wrapper element. |
style | style object | Styles the wrapper element. Use it to set the size. |
Use class with Vue and className with React or Preact. The chat itself uses Preact in
every integration, but no host ever passes it a Preact node: actions and emptyItems are
plain objects, so the same code works from Vue, React, Preact and plain JavaScript.
The chat has two rows of chrome. The title bar over the transcript shows the conversation
title first, then the two buttons that change which conversation it is: new conversation
and stored conversations, and last your actions at the corner. A settings or history page
replaces those two buttons with a back arrow in front of the title; your actions stay
where they are, because a page replaces the controls of the conversation and not the
chrome of the page around it.
The bar under the composer shows what is running, and reads from both ends. At the leading edge: the model, which also opens the settings page, and then the tool gate — Ask confirms tool calls before they run, Bypass lets them run, and one click swaps them. At the trailing edge: the context meter. The gate button appears only when the session has tools to gate; the Pi session opens on Bypass and keeps the choice for that session alone. The composer between the two rows is a text field and a send button: a pill while the message is one line, and the same corner around a box that grows with the message, with the button at the foot of the field.
actions takes one object per button, drawn at the top right of the title bar — use it for
host chrome such as a collapse button. label is the accessible name and the tooltip;
icon is either a name from the built-in set or the path data of a glyph Agentak does not
ship; text puts words on the button. variant, disabled and pressed are the rest —
pressed makes it a switch. The chat sizes and styles the button itself, so host chrome
matches the buttons beside it:
const actions = [
{ id: "close", label: "Collapse", icon: "panel-right-close", onClick: () => hide() },
{ id: "docs", label: "Open the docs", icon: { paths: ["M4 4h16", "M4 12h16"] }, onClick: open },
];emptyItems fills the empty state under the greeting, one object per item:
{ kind: "text", text } for a line of prose, { kind: "actions", actions } for a row of the
same buttons, and { kind: "element", name, props } for a renderer you registered by name
with registerElements() from agentak/components. That registry is the one place a host
renders its own component, and it is also how a transcript { kind: "element" } part is
drawn.
prompts puts starter messages at the foot of the empty chat, in one row of buttons under
the recent conversations. A click sends the message, so a reader starts a conversation
without typing. Give a string when the
button and the message are the same words, or { label, prompt } when the button is the
short of a longer message:
<ChatPanel
session={session}
prompts={[
"Summarize this page",
{ label: "Compare plans", prompt: "Compare the plans on this page in a table." },
]}
/>The buttons show only before the first message and go with the greeting. A click made before a provider is chosen still counts: the Pi session holds the message, opens the settings page, and sends it once a model is selected.
autoFocus is off by default because an embedded widget must not take focus from the host
page. It is useful when the chat is the whole document, as in the Chrome side panel. It is
ignored on touch devices to avoid opening the keyboard over the surface.
Use linkBase when the answer is about a document other than the one that contains the
chat. For example, a side panel passes the active tab URL so /settings points at that
site instead of the extension.
A link in an answer that lands on your own site is answered in place: the chat calls
history.pushState and raises a popstate, which your router already listens on, so the
page changes and the conversation beside it stays. Links to another site open a new tab, a
#fragment of the open page scrolls as usual, and a click with Ctrl, Cmd, Shift or Alt is
left to the browser. A host without a client-side router therefore gets a URL change and
no page change.
#Use AgentChat without a wrapper
AgentChat is the session-driven Preact surface without a host element:
import { render } from "preact";
import { AgentChat, injectTokens } from "agentak";
import { createPiSession } from "agentak/pi";
injectTokens();
const session = createPiSession();
render(
<AgentChat
session={session}
style={{ height: "600px" }}
actions={[{ id: "close", label: "Close", icon: "x", onClick: () => hide() }]}
emptyItems={[{ kind: "text", text: "Ask me about this page." }]}
/>,
document.querySelector("#app")!,
);Call injectTokens() once, or provide the exported tokens text in your own stylesheet.
Agentak uses inline component styles and no shadow root. Host CSS can still affect
inherited styles.
#Control the transcript with ChatView
ChatView wraps the presentational Chat component. It needs no session:
import { ChatView } from "agentak/react";
<ChatView
messages={messages}
isStreaming={streaming}
onSend={send}
onStop={stop}
onReset={clear}
onRetry={retry}
style={{ height: "600px" }}
/>;In Vue, callbacks are props rather than emitted events:
<ChatView
:messages="messages"
:is-streaming="streaming"
:on-send="send"
:on-stop="stop"
:on-reset="clear"
class="h-[600px]"
/>ChatView accepts all Chat state and callbacks, plus wrapper tokens, class, and
style. Optional state controls optional features: omit usage to hide the context meter,
omit providers and models for a fixed-model chat, or omit history for no history page.
#Included interaction details
The assembled chat handles all of these without host code:
- streaming answers with separate reasoning, Markdown, syntax-highlighted code, images, and tool calls
- approval and denial controls for gated tools, including a denial reason sent back to the model, and a switch in the bar between confirming every call and bypassing the gate
- messages sent during a turn as a visible queue, with removal before they run
- stop, retry, dismissible errors, and provider failures that open settings when the provider, account, key, or model needs attention
- provider, model, API key, and thinking-level settings in a full-height page
- stored conversation history in a second page, without unmounting the chat, and the three newest conversations at the foot of an empty chat
- host-declared starter prompts at the foot of an empty chat, each sent with one click
- context tokens and accumulated cost details, with an amber near-limit warning, and a Compact button in the same panel where the session can summarize the conversation
- copy and read-aloud actions under finished text answers
- retry and fork actions under each user message, which rewind the conversation
- relative-link safety through
linkBase /modeland/newcommands with keyboard selection and completion, and the agent's tools in the same list — picking one runs it, and the model answers from what it returned- a growing composer that lifts above mobile virtual keyboards
Copy and speech use only the text parts of the answer. Thinking and tool calls are left out. Read aloud converts Markdown to spoken text, selects a suitable browser voice, and splits long answers into short utterances.
Retry and fork both rewind to the message you clicked. Retry stays in the conversation on screen: it sends the message again and replaces the answer it got, together with everything said after it. Fork starts a new conversation from the turns before that message and puts the message back in the composer, so you can change a word and send it again; the conversation you forked from is stored, not edited, exactly as the new-conversation button stores it.
Each button appears only where the session supports it — retryFrom for retry, fork for
fork. Neither is the retry button in the error row, which runs a failed request again.
#Component library and custom transcript elements
agentak/components exports the chat parts, shadcn-style primitives, and Preact ports of
AI SDK Elements as named exports. Some exported components are building blocks that the
Pi transcript does not emit by default.
A custom transcript can carry { kind: "element", name, props }. Register its renderer
once:
import { registerElements } from "agentak/components";
registerElements({
weather: ({ temperature }) => <strong>{String(temperature)}°</strong>,
});Agentak registers image, checkpoint and progress itself because the Pi transcript
can emit them. An unknown element name renders nothing instead of breaking the transcript.
#Progress in a turn
A stream carries words, and a progress bar is not words. A ::progress{…} line in the
text or thinking of a turn is read back out as a bar:
::progress{id="model-load" value="42" label="Loading Granite 4.1 3B, 2.0 GB"}The line is the whole marker: id, value, max (100 by default) and label, quoted or
bare, with the closing brace ending it. A marker owns its line, so prose that mentions one
stays prose. Any other key is dropped. Without a value the bar is indeterminate and
sweeps.
A stream only appends, so a marker written again under the same id is the same bar,
updated in place: the fields it names change and the rest — usually the label, written
once — stay as they were. A download is therefore one marker per tick and one bar on
screen, not a trail of percentages. Half a marker at the end of a growing block is hidden
until its closing brace arrives.
A full bar has nothing left to report: it rests a second, fades, and leaves the turn. A bar that is already full when the conversation is read back out of storage never appears at all, so a finished download leaves no mark on the transcript.
Agentak's own local models use this for their one-time download. The words around the
markers are kept, so a turn can carry a bar and an answer. The Progress component behind
it is exported from agentak/components for a host that wants a bar of its own; there the
leaving is the hideWhenDone prop, and off by default.
#Session ownership
A session holds one live conversation at a time. It can replace that conversation in
place through history or restore(), so the widget stays mounted. A host can also replace
the session prop when it wants completely separate runtimes.
Whoever creates a session owns its cleanup. No wrapper calls dispose().