> ## Documentation Index
> Fetch the complete documentation index at: https://docs.waniwani.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Widgets

> A widget is a folder with two files: widget.ts holds the contract and ui.tsx the React component. One data schema serves as tool input, structured output and component props.

A folder at `widgets/<name>/` registers as the MCP tool `<name>` plus a `ui://` resource the host renders. It needs two files: `widget.ts`, which default-exports `defineWidget({ ... })`, and `ui.tsx`, which default-exports a React component.

```ts widgets/select-plan/widget.ts theme={null}
import { defineWidget } from "@waniwani/kit";
import { z } from "zod";

const plan = z.object({
  id: z.string(),
  label: z.string().describe("Short label, e.g. '3×'."),
  monthly: z.number(),
  fee: z.number(),
});

export default defineWidget({
  title: "Choose an instalment plan",
  description:
    "Show the instalment plan picker. Call this once check-eligibility has returned plans, passing them straight through. The widget renders every figure itself: do NOT list the plans in text.",
  data: {
    amount: z.number(),
    plans: z.array(plan).describe("Plans returned by check-eligibility, unmodified."),
  },
  llmText: (data) =>
    `The picker is on screen with ${data.plans.length} plans. Wait for the user to pick one.`,
});
```

```tsx widgets/select-plan/ui.tsx theme={null}
import { useLayout, useSendFollowUpMessage, useWidget } from "@waniwani/kit/web";
import widget from "./widget.js";

export default function SelectPlan() {
  const { data } = useWidget(widget);
  const sendFollowUp = useSendFollowUpMessage();
  const { theme } = useLayout();
  const root = theme === "dark" ? "dark" : "";

  if (!data) return <div className={`${root} font-sans text-ink-muted`}>Loading your plans…</div>;

  return (
    <div className={`${root} font-sans text-ink dark:text-slate-100`}>
      {data.plans.map((plan) => (
        <button
          key={plan.id}
          type="button"
          onClick={() => sendFollowUp(`I'll take the ${plan.label} plan.`)}
        >
          {plan.label}: €{plan.monthly}/month
        </button>
      ))}
    </div>
  );
}
```

## Why a widget is two files

`widget.ts` gets imported by the server and by the browser bundle, so it stays free of React and CSS. It carries one `data` schema, which serves as the tool's input schema, its structured output, and the type the component receives:

```tsx theme={null}
const { data } = useWidget(widget); // typed off `data`, no generated helpers
```

The usual approach puts `generateHelpers<AppType>()` in a shared file typed against the server, which makes a widget's type depend on the server's shape. Here the widget owns its own contract, so the two cannot drift.

`data` arrives as soon as the host has the tool input, which on most hosts happens before the server responds, so render optimistically and reach for `isReady` when you need the final value.

## Contract fields

<ResponseField name="title" type="string" required>
  Shown to humans in connector UIs, and used as the `title` annotation.
</ResponseField>

<ResponseField name="description" type="string" required>
  LLM-facing. When to show this widget, and how to frame it. The starter template's version tells the model what to say before calling and what not to repeat afterwards.
</ResponseField>

<ResponseField name="data" type="Shape" required>
  A plain Zod shape. Input schema, structured output and component props, all from this one definition.
</ResponseField>

<ResponseField name="hints" type="ToolHints">
  Same as on a [tool](/kit/tools#fields). `readOnly` defaults to `true` for widgets.
</ResponseField>

<ResponseField name="llmText" type="(data) => string">
  Text handed to the model alongside the rendered widget. Use it to say what the model should not repeat, and what it should wait for. Without it the runtime sends a default that tells the model the widget displays all the detail itself and to wait for the user to interact with it.
</ResponseField>

<ResponseField name="load" type="(input) => data | Promise<data>">
  Optional server-side loader, for widgets whose data comes from an API rather than from the model. Defaults to echoing the input through. A `load` that throws returns an error envelope telling the host something went wrong on your side and to offer a retry.
</ResponseField>

<ResponseField name="autoHeight" type="boolean" default={true}>
  Let the host size the widget's frame to its content. A card whose height depends on its data is cut off or padded out by any fixed frame. Set it to `false` for a widget that renders its own scroll area.
</ResponseField>

<ResponseField name="csp" type="WidgetCsp">
  <Expandable title="csp">
    <ResponseField name="connectDomains" type="string[]">
      Domains the widget may `fetch()`.
    </ResponseField>

    <ResponseField name="resourceDomains" type="string[]">
      Domains the widget may load images, fonts and scripts from. The origins of the template's stylesheet are merged in for you; see [Styling](/kit/styling).
    </ResponseField>

    <ResponseField name="redirectDomains" type="string[]">
      Domains the widget may open externally without the host's safe-link confirmation.
    </ResponseField>
  </Expandable>
</ResponseField>

## Hooks in ui.tsx

Everything a component needs comes from `@waniwani/kit/web`.

| Hook                                                                             | What it gives you                                                          |
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `useWidget(widget)`                                                              | `{ data, isLoading, isReady }`, typed off the widget's `data` shape        |
| `useWidgetState(initial)`                                                        | Per-widget state that survives re-renders and is handed back to the host   |
| `useSendFollowUpMessage()`                                                       | Send a message as the user, which is how a click moves the conversation on |
| `useCallTool()`                                                                  | Call one of the app's tools from the widget                                |
| `useLayout()`                                                                    | The host's layout, including `theme` for the `dark` class                  |
| `useDisplayMode()`, `useRequestSize()`, `useRequestModal()`, `useRequestClose()` | Ask the host to change how the widget is shown                             |
| `useOpenExternal()`, `useSetOpenInAppUrl()`                                      | Open links outside the host                                                |
| `useUser()`, `useFiles()`, `useDownload()`                                       | Read what the host shares about the user, and hand files back and forth    |
| `mountView`                                                                      | The entry the generator uses; you will not call it yourself                |

`useWidget().data` is the model's input until the server answers, so an `if (!data)` guard only covers the first paint. Guard on `isReady` for anything that must wait for `load()`.

## What a request does

```mermaid theme={null}
sequenceDiagram
    participant Host as ChatGPT / Claude
    participant Server as registerApp() (the shared runtime)
    participant App as your code

    Host->>Server: tools/call select-plan
    Server->>Server: validate against the widget's `data` schema
    Server->>App: load(input), optional
    App-->>Server: data
    Server->>Server: structuredContent + llmText + annotations
    Server-->>Host: result + ui:// resource
    Host->>Server: resources/read ui://widgets/.../select-plan.html
    Server-->>Host: HTML pointing at the built bundle
```

Every arrow that leaves `App` out is runtime code. Error envelopes, annotation defaults (including the `title` Claude's Connectors Directory requires), the "do not narrate the widget" instruction, the CSP block and tracking through `withWaniwani` all sit in one place, for every app.

## Calling from a flow

A [flow](/kit/flows) shows a widget by folder name, and the build check verifies that the folder exists:

```ts theme={null}
showWidget({ tool: "select-plan", field: "selectedPlanId", data: { /* … */ } })
```
