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

# Custom widgets

> When blocks aren't enough — your own HTML, sandboxed, with a strict bridge.

The built-in blocks should be your default: zero-effort, on-brand,
restyle-proof. But some results deserve pixels of their own — an order picker, a
seat map, a branded price card. A **widget** is a sandboxed HTML document you
ship as a block: full visual control inside a hard security boundary.

<Warning>
  Reach for a widget because the card **needs** it, not because you can. An
  unnecessary widget is a defect — it ages, drifts off-theme, and costs bytes.
  Blocks first.
</Warning>

## Returning a widget

A widget is just another glance block. The SDK's `widgetResult` helper wraps one
for you:

```ts theme={null}
import { widgetResult } from "@voiceos/integration-sdk";

return {
  content: [{
    type: "text",
    text: JSON.stringify({
      ...data,                                   // the model still needs data
      ...widgetResult(html, { height: 220, data: { items }, label: "Order picker" }),
    }),
  }],
};
```

Or as a raw block: `{ "type": "widget", "html", "height?", "data?", "label?" }`.
It counts against the normal 3-block budget, and can't sit inside a `row`.

| Field    | Rules                                                                          |
| -------- | ------------------------------------------------------------------------------ |
| `html`   | The complete document. ≤ 131,072 chars (128 KB).                               |
| `height` | Initial height in px, clamped **60–420** (default 180).                        |
| `data`   | Anything JSON-serializable, ≤ 16,384 chars — delivered to your script at init. |
| `label`  | Accessibility title for the frame (≤ 60 chars).                                |

## The sandbox

Your document renders in an iframe with `sandbox="allow-scripts"` and
deliberately **without** `allow-same-origin`:

|                   |                                                                                                                                    |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| **Opaque origin** | No parent DOM, no host APIs, no cookies, no storage.                                                                               |
| **No network**    | The frame inherits the notch's CSP, which doesn't allowlist your hosts. `fetch`, external images, fonts, and stylesheets all fail. |
| **Scripts run**   | Interactivity is the point — but `postMessage` is the only way in or out, and the host validates every message.                    |
| **No approval**   | Approval is structurally impossible from inside a widget: there is no message that confirms an action.                             |

<Tip>
  No network means **fetch in your tool handler**, then inline: data as JSON,
  images as `data:` URIs, styles inline, system font stack.
</Tip>

## The bridge

**Host → widget**, once, after load:

```js theme={null}
{ type: "voiceos:init", data, theme: { mode: "dark" | "light" } }
```

`data` is your block's `data` verbatim. Style for both modes and switch on
`theme.mode` — **never on `prefers-color-scheme`**, which follows the OS while
the notch stays dark.

<Warning>
  `args` (the pending tool-call arguments) is delivered **only to confirmation
  widgets**. A result widget's init carries `data` and `theme` — put everything
  the card needs in `data`.
</Warning>

**Widget → host:**

| Message           | Payload      | Effect                                        |
| ----------------- | ------------ | --------------------------------------------- |
| `voiceos:resize`  | `{ height }` | Host animates to the clamped height (60–420). |
| `voiceos:openUrl` | `{ url }`    | Opens in the browser. **https only.**         |

Full wire details: [Widget bridge reference](/integrations/reference/widget-bridge).

## A complete minimal widget

```html theme={null}
<!doctype html>
<meta charset="utf-8" />
<style>
  /* No scrollbar chrome ever — the card sits on black glass, and a desktop
     scrollbar breaks the illusion. Scrolling still works; the bar is gone.
     (Kit-built cards get this for free; hand-rolled documents copy it.) */
  * { scrollbar-width: none; }
  ::-webkit-scrollbar { display: none; }
  body { margin: 0; background: transparent; color: rgba(255,255,255,.95);
         font: 13.5px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; }
  html[data-theme="light"] body { color: rgba(0,0,0,.92); }
  .big { font-size: 24px; font-weight: 650; font-variant-numeric: tabular-nums; }
  .sub { color: rgba(255,255,255,.5); }
  html[data-theme="light"] .sub { color: rgba(0,0,0,.46); }
</style>
<div style="padding: 13px 15px 14px">
  <div class="big" id="count">–</div>
  <div class="sub">open pull requests</div>
</div>
<script>
  addEventListener("message", (e) => {
    const m = e.data;
    if (!m || m.type !== "voiceos:init") return;
    document.documentElement.dataset.theme = m.theme?.mode || "dark";
    document.getElementById("count").textContent = m.data?.count ?? "0";
    report();
  });
  new ResizeObserver(report).observe(document.documentElement);
  function report() {
    if (document.hidden || !document.documentElement.offsetWidth) return;
    const h = Math.ceil(document.documentElement.getBoundingClientRect().height);
    if (h >= 8) parent.postMessage({ type: "voiceos:resize", height: h }, "*");
  }
</script>
```

Three habits worth copying from it:

1. Theme comes from the bridge, not the OS.
2. A `ResizeObserver` reports real height.
3. A guard **refuses to report a collapsed measurement** — a hidden frame
   measuring 0 would otherwise pin your card at the 60px floor.

## The corner

Every card VoiceOS shows is cut at the same corner radius, and **the surface
does the cutting, not your document.** Your card arrives as a rectangle; the
host rounds it.

That matters the moment you paint anything to the card's own edge — a
background, a border, a header wash, a full-bleed image. Draw that at a radius
of your own and there are two corners: yours, and the host's arc slicing across
it a few pixels out.

So use the variable, never a number:

```css theme={null}
.card {
  background: linear-gradient(#1a1b1f, #101114);
  border: 1px solid var(--line);
  border-radius: var(--k-radius); /* not 12px, not 26px */
}
```

`--k-radius` is set before your CSS runs and is **26px** — the same corner the
notch cuts confirmation cards at. It's still not a constant you can inline: the
same `html` renders into more than one surface, and a surface whose clip is
concentric with a different shell uses its own value (VoiceOS's pill clips at
25\). The host sends its real value on `voiceos:init`, so `var(--k-radius)` is
correct everywhere and a literal is correct in exactly one place.

<Note>
  Only the OUTER corner is host-owned. Rows, chips, thumbnails, inputs, and
  avatars are yours — match the product you're fronting. If you build with the
  [Widget Kit](/integrations/widget-kit), its card shells already carry
  `border-radius: var(--k-radius)`.
</Note>

## Height, done right

Declare an honest `height` **and** report corrections. A good estimate alone
drifts as content varies; a correction alone means a visible jump on every card.
Measure your real card once, hardcode the estimate, keep the observer.

## Don't hand-roll it

Everything on this page — bridge, theming, resize guards, caps, degradation — is
implemented once in the **Widget Kit**, the same design system the Integration
Studio uses for every card it generates.

<Card title="The Widget Kit" icon="package" href="/integrations/widget-kit">
  Composable card components, brand accents with automatic contrast repair, and
  a bridge runtime you never have to write.
</Card>
