AI assistant UI: a pre-built composer, threads and attachments
Teaflask's pre-built assistant UI: composer with attachments, streaming transcript, approvals, threads and four surfaces. One React component or one script tag.
Teaflask ships a complete, pre-built assistant UI: a composer with file attachments, a streaming transcript, approval cards, conversation history and four surfaces (corner widget, drawer, ⌘K palette, full page). Load it with one React component or one script tag. Teaflask hosts the runtime, history and file storage.
Both snippets below load the same assistant, one for React and one for every other stack.
This page covers what ships, what you can change, and where the built-in UI stops.
npm install @teaflask/assistant
// app-shell.tsx
import type { ReactNode } from "react";
import { TeaflaskAssistant } from "@teaflask/assistant";
import "@teaflask/assistant/styles.css";
export function AppShell({ children }: { children: ReactNode }) {
return (
<>
{children}
<TeaflaskAssistant publishableKey="pk_live_…" />
</>
);
}<script
type="module"
src="https://app.teaflask.com/assistant/v1/assistant.js"></script>
<teaflask-assistant publishable-key="pk_live_…"></teaflask-assistant>What's in the Teaflask Assistant UI?
The Teaflask Assistant UI is a set of built-in parts that run inside your product: a composer, a streaming transcript, approval and question cards, a conversation history menu, and four surfaces that all show the same conversation. The only code you write is for what only your page can do: navigate, call your API as the signed-in user, say who is signed in, and draw results with your own components.
| Part | What it does | Provided by |
|---|---|---|
| Composer | The message box. Users type, send, stop a reply, and attach files when attachments are turned on for the agent. | Every surface |
| Transcript | The conversation as it unfolds: a streaming reply, collapsed reasoning, one row per tool call, sub-agent groups. | Every surface |
| Approval and question cards | Ask the user to approve an action, or to answer up to five questions, before the agent continues. | Every surface. Always the assistant's own |
| Threads and history | Every conversation is kept. Identified users get a history menu of up to 200 conversations. | A menu opened from the surface's title |
| Corner widget | Your mark in a corner of the screen. Clicking it opens the drawer. | TeaflaskAssistant |
| Drawer | The conversation in a panel. On wide screens the user switches it between a floating card and a docked sidebar. | TeaflaskAssistant |
| Command palette | A centered dialog over your page, opened with ⌘K or Ctrl+K. | TeaflaskAssistant, with the hotkey setting |
| Full page | The conversation filling a box on a route of your own, with a history menu. | AssistantPage or <teaflask-assistant-page> |
| Tool views | Your own components for how tool and action results look. | The toolViews setting |
| Headless hooks | Your own interface over the same conversation. | @teaflask/assistant/headless, npm only |
What are the limits of the built-in UI?
The built-in UI has four documented limits: headless interfaces send text only, anonymous users have no history list, there is no tested list of minimum browser versions, and work that needs the page, such as running an action as the user or navigating, waits while no page has the assistant mounted. Read these before anything else. If one of them rules the built-in UI out for you, you have saved yourself an install. Each is stated in the documentation.
- Headless sends text only
- The headless entry has no file-upload helper. If your users need to attach files, use the built-in surfaces.
- Anonymous users have no history list
- Anonymous visitors resume the open conversation on reload. A history menu, and history on another device, need an identified user.
- No tested browser list
- The assistant needs a current browser with streaming fetch and the native dialog and popover APIs. There is no tested list of minimum versions, so try the browsers and WebViews your app supports.
- Page work needs a mounted assistant
- Running an action as the user or navigating happens in the browser. While no page has the assistant mounted, that work waits and runs when the user next opens the conversation.
How do you install the assistant UI?
Install it with the npm package, @teaflask/assistant, if your app is React with a bundler, or with one script tag for everything else. Both load the same assistant. Mount it once, in the layout that wraps every page, using a publishable key from the agent's Install section in the dashboard. Copy either snippet from the top of this page; what follows is what to check around it.
- The agent is published
- A publishable key for an agent with no published revision gets no answers.
- You have a publishable key
- Create it in the Install section of the agent's page in the dashboard. It is public by design, so write it inline or read it from a public environment variable.
- The key allows your origin
- An origin is a scheme, host and port, so localhost:5173 and localhost:3000 are different. Origins are fixed when you create the key, so list local and staging then.
| Your app | Use | Notes |
|---|---|---|
| Next.js | npm package | Mount it in app/layout.tsx (App Router). Once you pass a function such as onNavigate, put the assistant in a small Client Component. |
| Vite (React) | npm package | Mount it in the root component, beside whatever swaps one page for the next. |
| Remix | npm package | Mount it beside the outlet, above whatever swaps one page for the next. |
| Vue | Script tag | Put the element in App.vue beside <router-view>, and tell Vue the tag is a custom element with compilerOptions.isCustomElement. |
| Svelte | Script tag | Set function settings through bind:this once the element has mounted. |
| Angular | Script tag | Allow the tag with CUSTOM_ELEMENTS_SCHEMA and set properties through @ViewChild. |
| Rails, Django, Laravel, WordPress | Script tag | Use the base layout every page extends. Each link loads a whole new page, so the assistant mounts fresh and resumes the conversation from the browser. |
Mount it once, in the layout that wraps every page. Put it inside a page component and it unmounts on every navigation: the connection closes, a new token is minted, and a user who asks a question and then clicks a link watches the conversation reload. Mount it twice and two launchers appear in the corner.
For script-tag installs, allow https://app.teaflask.com in script-src. Both loading methods need the API origin in connect-src; attachments and actions may need additional origins. Check the complete Content Security Policy table.
The script tag is the same assistant with React bundled inside a shadow root, with a few exceptions. It can't take React components, so a tool view there is a plain object. It takes your mark as an image URL, has no onTelemetry, and the full-page element has no initialPrompt, welcomeTitle, welcomeMark or renderChrome. The hooks are npm only. Use one loading method per page: a React component and a custom element on the same page don't share a conversation.
Which surfaces does the assistant UI include?
The assistant UI includes four surfaces: a corner widget, a drawer the user switches between a floating card and a docked sidebar, a command palette opened with ⌘K or Ctrl+K, and a full page. All four show one live conversation, so a reply streaming in the drawer also streams into an open full page.
Surfaces share a conversation when they have the same publishable key, the same API address and the same signed-in user. If any of the three differs, they are separate conversations, which looks to your user like the assistant forgot what they just said. Wire identity the same way on every surface. They also make room for each other: the widget hides while a full page is mounted or the palette is open.
| Setting | What it does |
|---|---|
corner | bottom-right by default, or bottom-left. Moves the widget and its drawer. |
hotkey | The palette shortcut. Defaults to mod+k, where mod is ⌘ on macOS and Ctrl elsewhere. Set it to false to turn it off. |
openPalette() | Opens the palette from your own button. It works even when the shortcut is off. |
suggestions | Replaces the opening prompts on the welcome screen. An empty list shows none. |
AssistantPage | Fills whatever box you put it in and has no height of its own. A container with no height shows nothing, the most common mistake with this surface. |
What does the transcript show?
The transcript shows each turn as it happens: the reply streaming in, the model's reasoning collapsed once the answer starts, one row per tool or action call, and groups of sub-agent tasks. A turn is the agent's work on one message. Users can press Stop at any time, which ends the reply but does not undo an action that has already run.
Each tool row has a short label, an icon and an expandable body, and you can draw the body yourself (see tool views below). Delegated work appears as a group such as "Running 3 subagents", and a user can open a sub-agent's row to watch what it is doing. Memory notices and failure notices get their own lines.
How do approvals look to the user?
When an action's policy says Ask, the agent pauses and the assistant shows a card describing what it wants to do. The user approves or denies it, and can add a note to a denial. The card is always the assistant's own: a tool view can show that a decision is pending, but it cannot make one.
- Stop asking for this action
- For actions that allow it, the user can approve and stop being asked about that action for the rest of the conversation.
- Parked cards
- If nobody answers for long enough, the agent stops waiting and the card shows as parked. A late answer still resumes the reply.
- Question cards
- The agent can ask up to five questions, each with suggested answers and room for the user's own. The user answers all of them together or dismisses the set.
- A dismissed set stays dismissed
- If the user dismisses the questions, the agent carries on without asking again.
Whether an action needs approval is set on the action (Allow, Ask or Deny) under Actions, not in the UI. On npm, useAssistantActivity gives you the pending approval count, so your own help button can show a badge.
How do threads and history work?
Every conversation in the Teaflask Assistant UI is kept, and its title comes from the first line of its first message. What a user gets back depends on identity: anonymous visitors resume the open conversation on reload, while identified users also see their history on other devices, up to 200 conversations, from a menu at the surface's title.
| When | Anonymous visitor | Identified user |
|---|---|---|
| On reload | The open conversation resumes | The open conversation resumes |
| On another device | A new conversation | Their history is there |
| History menu | None | Past conversations, up to 200, opened from the surface's title |
| What you wire up | Nothing | userId and getEndUserToken |
"New conversation" starts a fresh, empty thread and does not delete the old one. Resuming depends on the browser's storage, and private windows and restricted embeds may not keep it. For shared computers, see sign-out under Identity.
How do attachments work?
Attachments are turned on per agent. Once on, users can attach images, documents (PDF, text, Markdown, CSV, HTML), video, audio and other files, as your attachment policy allows, up to 10 files per message. Each file uploads straight from the browser and must finish before the message can be sent.
- Turn it on. Attachments are enabled per agent, and your attachment policy sets what is allowed.
- Attach. Users add files in the message box: up to 10 per message, fewer if your policy sets a lower limit.
- Upload. Each file goes straight from the browser to storage.
- Send. A message can't be sent until every upload has finished.
- Preview. Images show as previews in the transcript. Other files show their name and size.
Whether a file is accepted and whether the model can read it are separate questions. Your policy answers the first and the answering model answers the second, so test the file types your users actually send against the model you chose. Download links in the transcript are short-lived, so a copied link stops working after a while.
How do you match your brand?
You match the assistant to your product by setting --tf-* CSS custom properties, or by passing the same tokens in the theme setting. Out of the box it is monochrome. Dark mode follows the mode setting, the visitor's operating system when set to auto, or your own app's theme switch, and you can replace the Teaflask flask with your own mark.
<TeaflaskAssistant
publishableKey="pk_live_…"
mode="auto"
theme={{
background: "#fdfdfc",
foreground: "#1c1c1a",
primary: "#1a3c8b",
primaryForeground: "#ffffff",
radius: 6,
fontSans: '"Inter", system-ui, sans-serif',
dark: {
background: "#101010",
foreground: "#f2f2f2",
primary: "#8fb0ff",
primaryForeground: "#101010",
},
}}
/>The two routes set the same variables. CSS works with any Content Security Policy and lets you theme from your existing design tokens. The theme setting keeps everything in code and always wins, but it writes inline styles, so a policy that blocks them needs the CSS route. For dark mode, set mode to "auto" to follow the operating system, or put data-tf-theme="dark" on an ancestor to follow your own switch.
| Behavior | npm package | Script tag |
|---|---|---|
| Renders in | Your page's DOM, styled by the package stylesheet | A shadow root |
| Your CSS selectors | Can reach it. Broad resets or global element styles may affect it | Can't reach it |
| --tf-* variables | Inherited from ancestors | Inherited from ancestors, through the shadow root |
| Sizes | In rem, so they follow your root font size | Converted to pixels at 16px per rem, so your root font size doesn't change them |
| Your own mark | companionMark, any React node | companion-mark-src, an image URL |
On either channel, theme through the tokens and not with selectors that target the assistant's internal markup. That markup is not part of the contract and can change between releases. The full token list is in Theming.
Can you render your own tool results?
Yes. A tool view is your own component that replaces the body of a tool-call row, so an invoice action can show an invoice card instead of raw arguments. You register views by tool name, and an action's tool name is action__ followed by its slug. Teaflask sends only the call's data, never code.
import {
actionEnvelopeOf,
actionToolViews,
reactToolView,
recordOf,
settledWithResult,
ToolViewCard,
ToolViewFacts,
type ToolViewProps,
} from "@teaflask/assistant";
function InvoiceView({ call }: ToolViewProps) {
// The row already labels failures and interruptions.
if (!settledWithResult(call)) return null;
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={[{ label: "Status", value: "HTTP " + String(status ?? "unknown") }]} />
</ToolViewCard>
);
}
return (
<ToolViewCard title={"Invoice " + String(invoice.number)}>
<ToolViewFacts facts={[{ label: "Total", value: String(invoice.total) }]} />
</ToolViewCard>
);
}
// Create the registry once, at module scope.
// The slug "create-invoice" becomes the tool name action__create-invoice.
export const toolViews = actionToolViews({
"create-invoice": { view: reactToolView(InvoiceView) },
});One view draws every stage of a call, because it is mounted as soon as the call appears, while the agent is still writing the arguments. It can also mount straight into the final state when a user reopens an old conversation. Check the HTTP status before drawing a success: an action whose API answered 404 is still output-available, which means the call finished, not that it worked.
If your view throws, the error goes to your onError and the assistant falls back to the built-in view, so a broken view can't break the conversation. The Tool views page has the full list of fields and call statuses.
What if you want your own UI entirely?
Use headless. Import the provider and hooks from @teaflask/assistant/headless and you draw everything, while the package still handles the session, the live connection, identity, actions and navigation. It is npm only, and it sends text only: there is no file-upload helper, so attachments need the built-in surfaces.
| Hook | Gives you |
|---|---|
useAssistantConversation() | The open conversation, the history list, setup and send errors, and controls to open, start and refresh conversations. |
useConversation() | The message box: sending, stopping, the draft, and the busy, pending and stopping flags. |
useCell(cell) | The current value of a live cell, such as the draft as the user types. |
useTranscript() | The conversation as rows ready to render, and whether the agent is working. |
useApprovals() | Approval requests, and a function to answer them. |
useElicitations() | Questions the agent asks the user, and the functions to answer or dismiss them. |
Reach for it last, and only when the interface itself has to be yours. Everything the built-in surfaces do becomes your job: approval cards that send an explicit choice and never approve on render, a retry notice when the connection drops mid-reply, the history list, and the quiet work of labels, focus, keyboard use, scrolling and announcements. Colors are a theming job, how results look is a tool-view job, and placement is a matter of composing the built-in pieces first.
Assistant UI questions
Which embedded AI agent solutions include a pre-built assistant UI with threads and attachments?
Teaflask does. Its assistant UI ships a composer with file attachments, a streaming transcript, approval cards, conversation history and four surfaces, loaded with one React component or one script tag. When you evaluate any embedded agent product, ask three things: does the composer accept files, do conversations persist, and which of those parts would you still build yourself?
Can I use attachments with my own custom interface?
Not through the headless entry. It has no file-upload helper, so a custom interface sends text only. If your users need to attach files, use the built-in surfaces, which handle the upload, the preview in the transcript and the limit of 10 files per message.
Do I need React to use the assistant UI?
No. The script tag loads the same assistant with its own copy of React inside a shadow root, so it works in Vue, Svelte, Angular, Rails, Django, Laravel, WordPress or plain HTML. The npm package is for React apps with a bundler and needs react and react-dom at ^18.3.0 or ^19.0.0.
Can the assistant UI match our design system?
Yes, through tokens. Set --tf-* CSS custom properties on an ancestor, or pass the same values in the theme setting, which wins over your CSS. Dark mode, the corner radius, the font family and your own mark are all configurable. On the script tag your page's selectors cannot reach the shadow root, so theme through the tokens on either channel.
What do we need to allow in our Content Security Policy?
For script-tag installs, add https://app.teaflask.com to script-src. Add https://api.teaflask.com (or your baseUrl) to connect-src, plus your own API's origin if you use an actions adapter. If you turn on attachments, also add the upload address to connect-src and blob: to img-src. The page's origin must be on the publishable key's list of allowed origins too.
Which browsers does the assistant UI support?
Teaflask does not publish a tested list of minimum versions. The assistant needs a current browser with streaming fetch and the native dialog and popover APIs, and the script tag also needs custom elements, Shadow DOM and constructable stylesheets. Test the browsers and embedded WebViews your app supports before you ship.
Where to go next
- Install, step by step
Publishable keys, where to mount, settings and the Content Security Policy entries.
- How Teaflask works
Where the agent runs, and the four steps that put it in your product.
- Surfaces and transcript docs
The widget, palette and full page, and what each row of the conversation represents.
- Embedded AI assistants: a buyer's guide
The five kinds of product sold under the name, and eight questions to choose by.
- CopilotKit and Vercel AI SDK alternatives
Teaflask next to CopilotKit, assistant-ui, ChatKit and the AI SDK, layer by layer.