Assistant UI
Tool views
Every time the agent calls a tool or an action, the call appears in the conversation as a row: a short label, an icon, and a body that expands. By default the body shows the call's arguments, and actions get a built-in view of the request and your API's response. A tool view replaces that body with your own rendering. An order lookup can show an order card, and an invoice action can show the invoice.
How tool views work
Five facts explain everything else on this page.
Views are your code, running in your page
You write a view, register it in the browser, and it ships with your page like any other code. Teaflask sends only the call's data (its arguments, its status and its result) and never sends code. Your view decides how that data looks.
A view is matched to a call by tool name
You register views in a registry keyed by tool name. Every call with that name gets your view. An action's tool name is action__ followed by its slug, which is shown on the action's page under Actions, so an action with the slug create-invoice runs as action__create-invoice.
For each call, the assistant uses the first of these that exists:
- a view in your registry under the call's tool name,
- the package's built-in view for that tool, such as the built-in action view,
- the default view, which shows the call's arguments.
Your registry comes first, so registering a view for an action replaces the built-in action view for it.
A view lives as long as the call
The assistant mounts your view once per call, as soon as the call appears, while the agent is still writing its arguments. From then on it sends every change to that same view: the arguments completing, the call running, a result landing, an approval opening or closing, the theme switching between light and dark. The view is removed only when its row is.
So one view draws every stage of a call, not only the result. It's mounted before the arguments are complete, while args is still {}. It's mounted while the call waits for approval. And it's mounted when the call fails.
A view is also mounted for calls that finished long ago, for example when a user reopens an old conversation. It then mounts straight into the final state and may never receive an update. Draw from whatever state the call is in when the view mounts.
A view presents; it never decides
If a call needs the user's approval, the assistant shows its own approval card. Whether an action needs approval is set on the action under Actions. Your view is told a decision is pending, through awaitingDecision, so it can say so, but it has no way to approve or deny. A view can't change what happens, only how it looks.
A broken view can't break the conversation
If your view throws while mounting or updating, the error goes to your onError and the assistant falls back to the next match: the built-in view, or the default view.
Register a view
A registry is an object whose keys are tool names and whose values hold a view. In React, write a component and wrap it with reactToolView:
import { reactToolView, TeaflaskAssistant } from "@teaflask/assistant";
import { InvoiceView } from "./invoice-view";
const toolViews = {
"action__create-invoice": { view: reactToolView(InvoiceView) },
};
export function Assistant() {
return <TeaflaskAssistant publishableKey="pk_live_…" toolViews={toolViews}/>;
}
For actions, actionToolViews writes the action__ prefix for you. You key it by the slug alone, and it returns exactly the registry above:
import { actionToolViews, reactToolView } from "@teaflask/assistant";
import { InvoiceView } from "./invoice-view";
const toolViews = actionToolViews({
"create-invoice": { view: reactToolView(InvoiceView) },
});
// The same as { "action__create-invoice": { view: reactToolView(InvoiceView) } }
To register views for actions and for other tools together, spread both into one object: { ...actionToolViews({ … }), some_tool: { view } }.
Create the registry once, at module scope as here. If you build it inside a component, every render makes a new view, so the assistant remounts your views and they lose their state.
What a view receives
A view receives { call, context } when it mounts and with every update. context.themeMode is the palette the assistant is painted in, "light" or "dark". Read it from there, because your page can't tell which palette the assistant is using.
call describes the call:
| Field | What it holds |
|---|---|
toolName |
The tool's name, action__<slug> for an action |
toolCallId |
The call's id, stable for its whole life |
status |
Where the call is in its life (see the next section) |
awaitingDecision |
true while an approval or question tied to this call is waiting on the user |
args |
The arguments, parsed. {} until they are complete |
result |
The parsed result, once it arrives. For an action, your API's response body |
resultText |
The result exactly as recorded |
truncated |
true if the recorded result was cut to fit a size limit. result is then missing and resultText holds the cut text |
errorText |
Why the call failed, when it failed |
refusalText |
Why Teaflask declined the call, when it did |
argsSchema, resultSchema |
The tool's JSON Schemas, when it declared them. For an action, built from your API's operation |
Treat every optional field as possibly missing, because a call exists before its arguments or result do. Never change call: each update brings a fresh copy.
A call's life
status takes one of eight values. In every one of them your view stays mounted and whatever it renders is shown. The assistant never hides it. What changes is how the row around it behaves:
| Status | Meaning | The row |
|---|---|---|
input-streaming |
The agent is still writing the arguments. args is {} |
Open, showing your view |
input-available |
The arguments are complete. The call is running, or waiting for approval if awaitingDecision is true |
Open, showing your view. While approval is pending, a "Needs input" label |
output-available |
The call finished and its result is in | Open, showing your view |
output-error |
The call failed | Closed, labelled "Failed". Opening it shows your view, then the error sentence |
denied |
The user didn't approve it | Closed, labelled "Not approved". Opening it shows your view |
cancelled |
It never ran, for example because the user pressed Stop | Closed, labelled "Interrupted". Opening it shows your view |
refused |
Teaflask declined it before it reached the tool | Closed, labelled "Declined". Opening it shows your view, then Teaflask's reason |
superseded |
It was cut off when the run restarted, so no result will arrive | Closed, labelled "Interrupted". Opening it shows your view |
"Open" applies while the call belongs to the latest turn, and not when the result was cut short; those rows stay closed too. Once the user sends their next message, rows go back to closed until someone opens them. Rows with a built-in or default view never open on their own. Registering a view is what tells the assistant the call is worth showing.
For the last five statuses, the row already says what happened, and for failures and refusals it prints the reason below your view. You decide what your view shows there. We recommend showing only what's still true, such as what was requested, or nothing at all. That way the reason doesn't appear twice, and nothing in your view looks like a success. resultText is withheld for those endings, because its content was meant for the agent, not the user.
settledWithResult(call) is true when the status is output-available. output-available means the call finished, not that it succeeded. An action whose API answered 404 is output-available too.
Reading an action
An action's args are grouped by where they go in the request. There's one key for each part your API operation declares:
{
"path_params": { "invoice_id": "inv_123" },
"query": { "include_lines": true },
"body": { "customer_id": "cus_42", "amount": 1200 }
}
An action's resultText records your API's response:
{ "ok": true, "result": { "status": 201, "body": { "id": "inv_123" }, "truncated": false } }
call.result is the body part alone. To read the status, use actionEnvelopeOf(call.resultText), which returns { status, body, truncated }, or null if the text isn't an action's recorded response. On the script tag, where the helpers aren't available, JSON.parse(call.resultText).result gives you the same three fields.
| Field | Meaning |
|---|---|
status |
The HTTP status your API returned, or null if none was recorded. A request adapter's results are always recorded as 200, whatever your API returned |
body |
Your API's response body: any JSON value, or text |
truncated |
true if your API's body was over about 16,000 characters and was cut before reaching the agent |
Check for a 2xx status before you draw something as a success. A create action usually returns 201, not 200.
An action ends in output-error only when no response came back: the network failed, or your adapter threw. Any response your API sends, 404 and 500 included, is output-available with that status. If you want more than the row's own error line, humanFailureSentenceOf(call.errorText) gives you a sentence fit for the user, and actionFailureOf(call.errorText) reads out the HTTP status, error code and field problems when your adapter's error carried them.
A view in React
This view draws an invoice action at every stage:
import {
actionEnvelopeOf,
recordOf,
settledWithResult,
ToolViewBadge,
ToolViewBones,
ToolViewCaption,
ToolViewCard,
ToolViewFacts,
ToolViewTitle,
type ToolViewProps,
} from "@teaflask/assistant";
export function InvoiceView({ call }: ToolViewProps) {
const request = recordOf(call.args.body);
const requested = [
{ label: "Customer", value: String(request?.customer_id ?? "…") },
{ label: "Amount", value: String(request?.amount ?? "…") },
];
if (call.status === "input-streaming") {
return (
<>
<ToolViewTitle>Preparing invoice</ToolViewTitle>
<ToolViewBones lines={2}/>
</>
);
}
if (call.status === "input-available") {
return (
<ToolViewCard title="New invoice">
<ToolViewFacts facts={requested}/>
{call.awaitingDecision && <ToolViewBadge>Awaiting approval</ToolViewBadge>}
</ToolViewCard>
);
}
if (!settledWithResult(call)) {
return null; // The row already labels the ending and prints any reason.
}
const status = actionEnvelopeOf(call.resultText)?.status ?? null;
const invoice = recordOf(call.result);
if (status === null || status < 200 || status >= 300 || invoice === null) {
return (
<ToolViewCard title="Invoice not created">
<ToolViewFacts facts={requested}/>
<ToolViewCaption>HTTP {status ?? "unknown"}</ToolViewCaption>
</ToolViewCard>
);
}
return (
<ToolViewCard title={`Invoice ${String(invoice.number)}`}>
<ToolViewFacts
facts={[requested[0], { label: "Total", value: String(invoice.total) }]}/>
<a href={String(invoice.pdf_url)} target="_blank" rel="noreferrer">
Download PDF
</a>
</ToolViewCard>
);
}
reactToolView keeps your component mounted for the whole call and re-renders it on each update, so component state survives. It renders in a React root of its own, so your app's providers (router, theme, data clients) don't reach it. Pass what a view needs through module imports or closures. For the same reason a view can't use your router: ToolViewRow with an href is a plain link that loads the page in the same tab. For a new tab, render your own <a target="_blank">, as above.
ToolViewProps takes optional type parameters for your arguments and result, ToolViewProps<Args, Result>, if you want call.args and call.result typed.
A view without React
Underneath, every view is an object with a mount method. reactToolView makes one from a component, and you can write one directly for plain DOM or any other framework:
| Method | Called | Your job |
|---|---|---|
mount(container, props) |
Once per call | Draw into container from props, which may already be the final state, and return { update, destroy } |
update(props) |
On every change | Apply the new props to what you drew. Keep the user's state in the view, such as an open section |
destroy() |
When the row goes away | Remove listeners, timers and anything else you created |
This version uses only the DOM, so it works with or without a bundler, including on the script tag:
import type { ToolViewAdapter, ToolViewProps } from "@teaflask/assistant";
export const invoiceView: ToolViewAdapter = {
mount(container, props) {
const root = document.createElement("div");
root.style.color = "var(--tf-foreground)";
container.append(root);
function line(text: string) {
const p = document.createElement("p");
p.textContent = text;
return p;
}
function render({ call }: ToolViewProps) {
const request = (call.args.body ?? {}) as Record<string, unknown>;
if (call.status === "input-streaming" || call.status === "input-available") {
root.replaceChildren(
line(`New invoice for ${String(request.customer_id ?? "…")}`),
...(call.awaitingDecision ? [line("Awaiting approval")] : []),
);
return;
}
const recorded = call.resultText ? JSON.parse(call.resultText).result : null;
const invoice = call.result as Record<string, unknown> | undefined;
const created = recorded?.status >= 200 && recorded?.status < 300;
root.replaceChildren(
...(call.status === "output-available" && created && invoice
? [line(`Invoice ${String(invoice.number)}`), line(`Total ${String(invoice.total)}`)]
: []),
);
}
render(props);
return { update: render, destroy: () => root.remove() };
},
};
Use one render function for both mount and update, as here, so the two can't drift apart. Set text with textContent, never by putting API values into innerHTML.
If your page bundles the npm package, its plain-DOM building blocks (see below) and readers such as actionEnvelopeOf save you the hand-written parts.
Styling a view
Where your view's DOM lives depends on how you loaded the assistant:
| npm package | Script tag | |
|---|---|---|
container is in |
Your page's DOM | The element's shadow root |
| Your page's stylesheets | Apply | Don't reach it |
| Style it with | Your own CSS classes | Inline styles, or a <style> element your view appends inside container |
On both, the theme's --tf-* variables are available inside the view, so color: var(--tf-foreground) and border-color: var(--tf-border) match the assistant's palette in light and dark mode. The building blocks below come styled already.
Views on the script tag
The element takes the same registry as a property. Write keys as full tool names, and views as mount objects like the one above, saved as a plain JavaScript module. The element can't take React components, and the loader doesn't expose the package's helpers.
<script type="module">
import { invoiceView } from "/static/js/invoice-view.js";
document.querySelector("teaflask-assistant").toolViews = {
"action__create-invoice": { view: invoiceView },
};
</script>
You can set toolViews before or after the element loads, and views set later also apply to calls already on screen. Each element has its own registry. If your page has both <teaflask-assistant> and <teaflask-assistant-page>, set it on both.
Building blocks
The npm package exports the parts its own views are built from, so yours can match them:
| React | Plain DOM | Use it for |
|---|---|---|
ToolViewCard |
card |
A bordered card, with an optional small title above it |
ToolViewTitle |
The same small title on its own, above skeleton lines, which have no card | |
ToolViewFacts |
factRows |
Label and value rows |
ToolViewRow |
listRow |
A list row with an optional icon, a second line and trailing content |
ToolViewBadge |
A small outlined label for a short state, such as "Awaiting approval" | |
ToolViewCaption |
captionRow |
A quiet line, such as a count or a status |
ToolViewBody, ToolViewScroll |
pane, scrollRegion |
Long content in a scrolling area |
ToolViewNote |
note |
One muted sentence for something missing or degraded |
ToolViewBones |
boneRows |
Skeleton lines while a call runs |
terminal |
Command output |
Their props and arguments are in the package's types.
Custom icons
A registration can carry an icon as well as, or instead of, a view. The icon replaces the small glyph beside the row's label. It has the same mount, update and destroy lifecycle, and reactToolView can make one from a component:
const toolViews = actionToolViews({
"create-invoice": {
view: reactToolView(InvoiceView),
icon: reactToolView(InvoiceIcon),
},
});
More resources
- Actions adapter – how an action's request is made and what its response contains
- Actions – action slugs, and which actions need approval
- Transcript – the rows your views sit in, and the approval cards they never replace
- Theming – the
--tf-*variables your views can use to match the assistant - Headless – rendering tool rows yourself in a custom interface