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](/appearance). To draw your own results, use [Tool views](/tool-views). To move or combine the built-in pieces, see [Surfaces](/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:

- `TeaflaskAssistantProvider` holds the session. It takes the same settings as `TeaflaskAssistant`: the key, the identity pair, `onNavigate`, `actionsAdapter` and 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.

```tsx
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](/tool-views), 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](/transcript).

## More resources

- [Surfaces](/surfaces) – composing the built-in pieces before replacing them
- [Tool views](/tool-views) – drawing tool and action rows with your own components
- [Transcript](/transcript) – what each kind of row represents
- [Identity](/identity) – passing the identity pair to the provider