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](/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:

1. a view in your registry under the call's tool name,
2. the package's built-in view for that tool, such as the built-in action view,
3. 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](/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`:

```tsx
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:

```tsx
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:

```json
{
  "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:

```json
{ "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:

```tsx
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:

```ts
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.

```html
<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:

```tsx
const toolViews = actionToolViews({
  "create-invoice": {
    view: reactToolView(InvoiceView),
    icon: reactToolView(InvoiceIcon),
  },
});
```

## More resources

- [Actions adapter](/adapters) – how an action's request is made and what its response contains
- [Actions](/actions) – action slugs, and which actions need approval
- [Transcript](/transcript) – the rows your views sit in, and the approval cards they never replace
- [Theming](/appearance) – the `--tf-*` variables your views can use to match the assistant
- [Headless](/headless) – rendering tool rows yourself in a custom interface
