Assistant UI
Headless
Headless lets you build your own interface on top of the assistant's conversation. You draw everything. The package still handles the session, the live connection, identity, actions and navigation, exactly as it does for the built-in surfaces.
Reach for it last. To change colors, use Theming. To draw your own results, use Tool views. To move or combine the built-in pieces, see Surfaces. Headless is for when the interface itself has to be yours.
How headless works
The built-in surfaces are a layer over a conversation store, and headless gives you that store directly:
TeaflaskAssistantProviderholds the session. It takes the same settings asTeaflaskAssistant: the key, the identity pair,onNavigate,actionsAdapterand the rest. It renders nothing.- Hooks inside the provider read the live conversation and act on it. Every hook reads the same conversation, so a custom interface and a built-in surface with the same key and identity stay in step, like any two surfaces.
- Your interface re-renders as the hooks' values change. You never call Teaflask's API yourself.
Headless is npm only. It's imported from @teaflask/assistant/headless, which carries the provider and hooks with no interface code.
The hooks
| 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: sendMessage, stopTurn, the draft, and the busy, pendingSend and stopping flags |
useCell(cell) |
The current value of a live cell, such as composerInput, which holds the draft as the user types |
useTranscript() |
The conversation as rows ready to render, and whether the agent is working |
useApprovals() |
Approval requests and submitDecision to answer them |
useElicitations() |
Questions the agent asks the user, and the functions to answer or dismiss them |
Each hook must be called inside the provider. useTranscript also does a job of its own: keep it mounted while your transcript is on screen, because that's what makes the assistant connect and load the conversation's history.
A minimal interface
This text-only interface shows the wiring: sending, stopping, errors and rows. It's a starting point, not a replacement for the built-in surfaces.
import {
TeaflaskAssistantProvider,
useAssistantConversation,
useCell,
useConversation,
useTranscript,
} from "@teaflask/assistant/headless";
export function CustomAssistant() {
return (
<TeaflaskAssistantProvider publishableKey="pk_live_…">
<Conversation/>
</TeaflaskAssistantProvider>
);
}
function Conversation() {
const session = useAssistantConversation();
const composer = useConversation();
const { draft } = useCell(composer.composerInput);
const transcript = useTranscript();
const error =
session.setupError?.message ?? session.sendError ?? session.turnFailure;
async function send() {
const text = draft.trim();
if (!text || composer.busy || composer.pendingSend) return;
composer.setDraft("");
if (!(await composer.sendMessage(text))) composer.setDraft(text);
}
return (
<section aria-label="Assistant">
<ol>
{transcript.displayRows.map((row) => (
<li key={row.key}>
{row.kind === "user" || row.kind === "assistant-text"
? row.text
: `[${row.kind}]`}
</li>
))}
</ol>
{error && <p role="alert">{error}</p>}
<form
onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<textarea
aria-label="Message"
value={draft}
onChange={(event) => composer.setDraft(event.target.value)}
/>
{composer.busy && !composer.pendingSend ? (
<button
type="button"
disabled={composer.stopping}
onClick={() => void composer.stopTurn()}
>
{composer.stopping ? "Stopping…" : "Stop"}
</button>
) : (
<button type="submit">Send</button>
)}
</form>
</section>
);
}
sendMessage resolves true once Teaflask accepts the message, and false if it was refused, in which case sendError says why. It doesn't touch the draft, so clear it yourself and restore it on refusal, as above.
displayRows already includes the message the user has just sent, shown straight away until it's confirmed. Don't draw your own copy of it, or it will appear twice.
Rendering rows
Switch on each row's kind:
kind |
What it is |
|---|---|
user |
A message the user sent, with any attachments |
assistant-text |
Reply text, as Markdown. While streaming is true the text is still growing and may end mid-sentence |
reasoning |
The model's visible thinking. Collapse it once streaming turns false |
tool-call |
A tool or action call, with its state, arguments and result |
subagent-group |
A group of delegated tasks |
subagent-delivery |
A divider before a reply the assistant started on its own to report finished delegated work |
turn-failed-receipts |
Sentences explaining why a reply failed, written to be shown as they are |
Rendering Markdown is up to you. For tool-call rows, toolCallPresentationOf from @teaflask/assistant works out the label, icon and matching tool view, the same way the built-in transcript does.
What a complete interface handles
The minimal example leaves most of the job undone. Before you put a custom interface in front of users, handle each of these:
| Situation | What your interface does |
|---|---|
| An action needs approval | Render useApprovals().cards by each card's status, and send the user's explicit choice with submitDecision(card, { approved }). Never approve on render. Offer "don't ask again" (trust: true) only when the card's trustAvailable is true |
| The agent asks a question | Show the questions from useElicitations() and send the answers, or the dismissal, with its functions |
| The user stops a reply | Show the stopping state. Stopping doesn't undo work already done |
| The connection drops mid-reply | When showInterruptionBanner is true, show a notice whose Retry calls retryStream |
| The agent waits on something you can't show | When pendingDecisionGap is set, show a notice whose Retry calls refreshConversation |
| An identified user wants an old conversation | List session.threads and open one with session.openThread(thread). Anonymous visitors have no history |
| Keyboard and screen-reader users | Labels, focus, keyboard use, scrolling and announcements are yours |
The headless entry has no file-upload helper, so a custom interface sends text only. If your users need to attach files, use the built-in surfaces.
Showing activity without a transcript
For a badge or busy dot elsewhere in your interface, use useAssistantActivity rather than mounting a hidden transcript. See Transcript.
More resources
- Surfaces – composing the built-in pieces before replacing them
- Tool views – drawing tool and action rows with your own components
- Transcript – what each kind of row represents
- Identity – passing the identity pair to the provider