# Widget integration

> Drop Hatched widgets into any page with one loader script and stable data-hatched-mount attributes.

Source: https://docs.hatched.live/docs/guides/widget-integration

Widgets are the presentation layer: small Shadow DOM UI pieces that render
buddy state directly in your product without leaking CSS in either direction.

## The short version

```html
<script
  src="https://cdn.hatched.live/widget.js"
  data-session-token="WIDGET_SESSION_TOKEN"
  data-surface="paper"
  data-geometry="rounded"
  data-motion-profile="standard"
  data-reward-voice="joyful"
  data-iconography="geometric"
  data-typography="sans-serif-modern"
  defer
></script>

<div data-hatched-mount="buddy"></div>
<div data-hatched-mount="badges"></div>
<div data-hatched-mount="streak" data-streak-key="daily_lesson"></div>
<div data-hatched-mount="path"></div>
<div data-hatched-mount="tokens"></div>
<div data-hatched-mount="marketplace"></div>
<div data-hatched-mount="leaderboard"></div>
```

The loader reads the token from its own script tag, discovers known
`data-hatched-mount` elements,
downloads only the bundles present on the page, and keeps them in sync through
one shared widget state poller.

The browser global is `window.__HATCHED_WIDGET__`. If a SPA creates mount
elements after the loader has already run, call
`window.__HATCHED_WIDGET__?.init({ token })` after those elements exist. The
required identity field is `token`; user and buddy identity come from the JWT.
Advanced endpoint and theme overrides are documented in
[Runtime configuration](#runtime-configuration).

## From onboarding to production

Onboarding scans the operator's site, extracts a brand brief plus visual identity
evidence (palette, typography, motifs), and seeds three widget defaults on the
customer: `widget_theme_config`, `widget_custom_css`, and `widget_size`. The
Dashboard Widget Studio preview and the install snippets both read those same
settings, so the widget a teammate approves in the dashboard is the widget that
ships in production.

> **The shortest path:** open Widget Studio → pick a preset (or click **AI from
> theme** so the loader rebuilds the theme from your scanned brand evidence) →
> dial in personality, size, and CSS hooks → **Save**. Your live page picks up
> the new theme on next load or focus — see [Live theme sync](#live-theme-sync)
> below.

Use the dashboard-generated snippet as the source of truth when possible. It
includes the current personality axes, theme variables, custom CSS hook
overrides, size, and the correct mount id for each widget. Manual edits should
use the public `data-*` attributes, `--hw-*` variables, and `.hw-*` class hooks
below so future widget releases can keep the same customization contract.

## Live theme sync

The loader fetches `/widget/theme` on init (using the same widget token you
already pass) and on browser focus / tab visibility change. The response carries
the customer's current `widget_theme_config` (preset, personality, vars),
`widget_custom_css`, and `widget_size`. If anything differs from the inline
`data-*` attributes, the loader hot-swaps the widget — surface, geometry,
motion, reward voice, iconography, typography, palette and density all update
without a page reload.

What this means in practice:

- **You do not have to re-paste the snippet** every time a teammate changes a
  color or switches a personality dial in Widget Studio.
- The inline `data-surface` / `data-geometry` / `data-motion-profile` /
  `data-reward-voice` / `data-iconography` / `data-typography` /
  `data-theme-vars` / `data-custom-css` attributes still ship as **fast-paint
  fallbacks** so SSR/no-JS or first-render scenarios get the right widget
  immediately, but the API answer is authoritative.
- Theme refresh respects a 5s minimum interval and a 15s `Cache-Control` on the
  endpoint so it stays cheap even on heavily-trafficked partner pages.

### Theme inheritance precedence

When the same theme attribute is set in more than one place, Hatched resolves
in this order — last write wins:

1. **Onboarding scan defaults** — the brand brief seeded at signup (palette,
   typography, motifs). Lowest priority; only used when nothing else is set.
2. **Widget Studio saved theme** — what a teammate approved in the dashboard.
   Persisted on the customer and served by `/widget/theme`. This is the
   *production* answer.
3. **Inline `data-*` attributes** on the loader `<script>` — fast-paint
   fallback for SSR / first paint. Overridden by `/widget/theme` on init,
   on focus, and on browser tab visibility change.
4. **Per-mount instance identity / view** — `data-*` attributes on the
   individual mount
   `<div data-hatched-mount="streak" data-streak-key="daily_lesson">`. These
   carry only instance identity and view selection (`data-streak-key`,
   `data-path-key`, `data-display-mode`) for that one mount. They do **not**
   override personality or theme — personality and theme are read from the
   loader `<script>` and `/widget/theme` only, so this is not a theme
   precedence level.
5. **Runtime `init({ themeVars })`** — programmatic overrides passed to
   `window.__HATCHED_WIDGET__.init(...)`. Highest priority; intended for
   demo pages, A/B harnesses, embedded preview surfaces.

In short: dashboard is authoritative, inline is fallback, runtime is escape
hatch. Production sites typically resolve their *theme* from levels 1–3; level
4 carries per-mount instance identity (not theme), and level 5 (runtime
`themeVars`) exists for the demo + admin preview surfaces and rarely belongs in
a partner page.

## Token choice

Use a **widget session token** for interactive widgets. Mint it on your server
with a secret API key:

```ts
import { HatchedClient } from '@hatched/sdk-js';

const hatched = new HatchedClient({
  apiKey: process.env.HATCHED_API_KEY!,
});

const session = await hatched.widgetSessions.create({
  buddyId: 'bdy_abc',
  userId: 'user_42',
  scopes: ['read', 'events:track'],
  ttlSeconds: 60 * 15,
});
```

The `read` and `events:track` scopes work on every plan. The marketplace
scopes — `marketplace:browse`, `marketplace:purchase`, `items:equip` — are
**plan-gated**: requesting them on a plan without the `marketplace` capability
makes the mint call fail with `403 plan_feature_locked`. Add them only for a
buddy whose customer is on a marketplace-capable plan:

```ts
const shoppingSession = await hatched.widgetSessions.create({
  buddyId: 'bdy_abc',
  userId: 'user_42',
  scopes: ['read', 'events:track', 'marketplace:browse', 'marketplace:purchase', 'items:equip'],
  ttlSeconds: 60 * 15,
});
```

Use a **read-only embed token** only when nothing on the page is
interactive. The embed token is a short-lived JWT minted by your backend per
render, then handed to the browser. It is *not* an API key and is not created
once in the dashboard — every page render mints a fresh one:

> **Read-only means read-only.** The hatch ceremony does **not** play on an
> embed token — a player whose buddy is still an egg stays on the egg — and
> every user write (event tracking, purchases, kudos) returns `403`. If the
> page hosts any interactive widget, use the session-token install above.

```ts
// On your server (route handler, server component, etc.)
const embed = await hatched.embedTokens.create({
  buddyId: 'bdy_abc',
  userId: 'user_42',
  ttlSeconds: 60 * 60,
});
// Send embed.token down to the browser.
```

Then inline that token into the loader script attribute:

```html
<script
  src="https://cdn.hatched.live/widget.js"
  data-embed-token="EMBED_TOKEN"
  defer
></script>
<div data-hatched-mount="buddy"></div>
```

See [Auth model → Embed token](/docs/concepts/auth-model#embed-token) for
the full lifecycle, raw-HTTP form, and how it differs from a widget session
token.

### Token matrix

The widget session token is the default install — it covers everything below.
The embed column only marks the displays that *also* work read-only.

| Widget / action                | Token to use                                  |
| ------------------------------ | --------------------------------------------- |
| Hatch ceremony (egg → buddy)   | Widget session token **only** — never plays on an embed token |
| Buddy display                  | Widget session token; embed token if the buddy is already hatched |
| Badges display                 | Widget session token or embed token           |
| Leaderboard display            | Widget session token or embed token           |
| Streak display                 | Widget session token or embed token           |
| Guided path display            | Widget session token or embed token           |
| Browser `track()`              | Widget session token with `events:track`      |
| Manual path sub-step completion | Widget session token with `events:track`      |
| Marketplace browse             | Widget session token with `marketplace:browse` (embed token shows a read-only catalog) |
| Marketplace purchase           | Widget session token with `marketplace:purchase` |
| Equip / unequip items          | Widget session token with `items:equip`       |
| Kudos send / quest join / box open | Widget session token with the matching scope — returns `403` on an embed token |

### Allowed browser origins

Widget runtime requests are allowed per customer. When onboarding is seeded
from a website URL, Hatched automatically adds that URL's origin to
`settings.widget_allowed_origins`. You can add local, staging, and production
app origins later in Dashboard → Settings → General → Widget allowed origins.
The allowlist is read from customer settings on each widget API request; it is
not baked into embed or session tokens.

Use origins only, not full paths:

```txt
https://app.example.com
http://localhost:4002
```

### Staging vs production

The loader picks its default API base from the URL it was loaded from:

| Loader URL                                  | Default API base                              |
| ------------------------------------------- | --------------------------------------------- |
| `https://cdn.hatched.live/widget.js`         | `https://api.hatched.live/api/v1`         |
| `https://cdn.hatched.live/staging/widget.js` | `https://api.staging.hatched.live/api/v1` |

So a snippet that points at the staging CDN automatically talks to the
staging API — no `data-api-base-url` override needed. If you _do_ pass
`data-api-base-url`, the value must be one of the canonical Hatched API
origins (`api.hatched.live`, `api.staging.hatched.live`); arbitrary
values are rejected to prevent token exfiltration via host-page XSS.

## Runtime configuration

The script tag is the preferred public configuration surface. The same values
can be passed to `window.__HATCHED_WIDGET__?.init(...)` when a SPA mints or
refreshes a token after hydration.

```ts
window.__HATCHED_WIDGET__?.init({
  token,
  apiBaseUrl: 'https://api.staging.hatched.live/api/v1',
  cdnBaseUrl: 'https://cdn.hatched.live/staging/widget/v1/',
  themeVars: { '--hw-accent': '#3F8F5F' },
  customCss: '.hw-container { box-shadow: none; }',
  personality: {
    surface: 'paper',
    geometry: 'rounded',
    motion_profile: 'standard',
    reward_voice: 'joyful',
    iconography: 'geometric',
    typography_pair: 'sans-serif-modern',
  },
  size: 'medium',
  lang: 'en',
});
```

| Init key       | Equivalent script attribute | Notes |
| -------------- | --------------------------- | ----- |
| `token`        | `data-session-token` or `data-embed-token` | Required unless already present on the script tag |
| `apiBaseUrl`   | `data-api-base-url`         | Must be a canonical Hatched API origin |
| `cdnBaseUrl`   | Derived from script `src`   | Advanced staging / self-hosted bundle override |
| `themeVars`    | `data-theme-vars`           | JSON object of `--hw-*` values |
| `customCss`    | `data-custom-css` / `data-custom-css-id` | Inline CSS or CSS script element |
| `personality`  | `data-surface`, `data-geometry`, etc. | Widget Studio writes these for you |
| `size`         | `data-size`                 | `small`, `medium`, or `large` |
| `lang`         | `data-lang`                 | Locale hint |

Per-widget instance identity belongs on the mount element. For example,
streak widgets read `data-streak-key` and path widgets read
`data-path-key` from the `<div>`, not from `init()`.

## Available mounts

| Mount attribute                         | Purpose                                            |
| --------------------------------------- | -------------------------------------------------- |
| `data-hatched-mount="buddy"`            | Animated companion, coins, stage, equipped items   |
| `data-hatched-mount="badges"`           | Earned and locked badge shelf                      |
| `data-hatched-mount="streak"`           | One or more streak counters; add a Dashboard streak `key` via `data-streak-key` |
| `data-hatched-mount="path"`             | Guided journey for the audience's active path; add `data-path-key` to pin a specific path |
| `data-hatched-mount="tokens"`           | Wallet card — spendable balance plus progression-token balances with progress toward each gate |
| `data-hatched-mount="marketplace"`      | Browse, buy, and equip items                       |
| `data-hatched-mount="leaderboard"`      | Community rank surface                             |
| `data-hatched-mount="kudos"`            | Peer recognition send + receive                    |
| `data-hatched-mount="group-quest"`      | Team-scoped quest progress (Growth+)               |
| `data-hatched-mount="feed"`             | Passive team event feed                            |
| `data-hatched-mount="mystery-box"`      | Skinner-box surprise reward (Growth+)              |
| `data-hatched-mount="league"`           | Season tier + cohort standing (Growth+)            |
| `data-hatched-mount="council"`          | UGC narrative co-authoring (Enterprise)            |
| `data-hatched-mount="hexad-survey"`     | Onboarding player-type survey                      |

Legacy ids such as `id="buddy-widget"` still work for older installs, but new
snippets should use `data-hatched-mount`.

Gated widgets still mount on every plan; the API responds with
`403 plan_feature_locked` until the customer upgrades, and the widget renders a
locked-state placeholder pointing at billing. The error details carry
`required_plan` and an `upgrade_url` so the placeholder can link straight to
the right upgrade. The full plan/capability ground truth lives in
[Plan capabilities](/docs/reference/plan-capabilities).

## Bundle sizes and loading

The 6 KB loader (`widget.js`) is all your page ships up front. Each widget
bundle is fetched on demand the first time its mount is found in the DOM, and
the Preact runtime plus shared UI code live in **separate cached chunks** that
every widget reuses — so adding a second or third widget to a page costs only
its own entry, not another copy of the framework.

| Widget        | First load (gzip) | Widget        | First load (gzip) |
| ------------- | ----------------- | ------------- | ----------------- |
| `buddy`       | ~87 KB            | `path`        | ~49 KB            |
| `marketplace` | ~71 KB            | `hexad-survey`| ~48 KB            |
| `leaderboard` | ~61 KB            | `group-quest` | ~47 KB            |
| `badges`      | ~56 KB            | `feed`        | ~46 KB            |
| `kudos`       | ~50 KB            | `streak`      | ~45 KB            |
| `tokens`      | ~45 KB            | `council`     | ~43 KB            |
| `league`      | ~44 KB            | `mystery-box` | ~41 KB            |

"First load" is the entry bundle plus the shared chunks it pulls, gzipped — the
worst case of a cold cache with that one widget alone on the page. The shared
chunk pool is ~65 KB gzipped in total and is downloaded **once per page**, so a
page with three widgets transfers roughly _(sum of the three entries) + (the
shared pool, once)_ rather than the sum of three "first load" figures. Repeat
visits and incremental deploys reuse content-hashed chunks straight from the
browser cache; only a chunk whose contents actually changed is re-fetched.

You don't host or version these files — they're served from
`cdn.hatched.live`, and the loader you embed decides which bundle build to pull
(see [Widget versioning](/docs/guides/widget-versioning)).

## Personality dimensions

Hatched widgets compose six personality axes that decide how the widget feels —
not just how it's coloured. The Widget Studio presets pick one value per axis;
the loader forwards each as a `data-*` attribute that drives Shadow-DOM CSS
attribute selectors. You can override any single axis without abandoning the
preset.

| Axis             | Attribute             | Values                                                                              | Drives                                                |
| ---------------- | --------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------- |
| Surface          | `data-surface`        | `paper` `glass` `metal` `crt` `parchment` `vellum`                                  | Shell background texture (grain, frost, scanlines)    |
| Geometry         | `data-geometry`       | `rounded` `cut` `pill` `sharp` `organic`                                            | Shell, card, button, badge clip + bar radii           |
| Motion profile   | `data-motion-profile` | `calm` `standard` `expressive` `theatrical`                                         | Idle bounce, hover lift, animation amplitude          |
| Reward voice     | `data-reward-voice`   | `quiet` `crisp` `joyful` `epic`                                                     | Coin pulse, level-up burst, badge glow intensity      |
| Iconography      | `data-iconography`    | `geometric` `hand-drawn` `pixel` `embossed` `flat-mono`                             | Icon stroke, wobble, pixelation, depth                |
| Typography pair  | `data-typography`     | `sans-serif-modern` `serif-classic` `mono-tech` `rounded-playful` `display-condensed` | Body + display font stacks                            |

Pick the combination that matches your brand. A fintech onboarding probably
wants `vellum + sharp + calm + crisp + geometric + sans-serif-modern`. A
gaming studio probably wants `crt + sharp + theatrical + epic + pixel +
mono-tech`. A wellness app probably wants `parchment + pill + calm + quiet +
hand-drawn + serif-classic`.

The Widget Studio's **AI from theme** action and the onboarding scout both
populate these axes from your scanned brand evidence, so the preset that
ships in production matches the one a teammate approved in the dashboard.

## Styling and CSS hooks

Widgets render inside Shadow DOM, so your product CSS cannot accidentally break
them. Customization is explicit: pass design tokens on the loader script and use
stable `.hw-*` hooks for deeper polish.

```html
<script
  src="https://cdn.hatched.live/widget.js"
  data-session-token="WIDGET_SESSION_TOKEN"
  data-surface="parchment"
  data-geometry="pill"
  data-motion-profile="calm"
  data-reward-voice="joyful"
  data-iconography="hand-drawn"
  data-typography="serif-classic"
  data-size="medium"
  data-theme-vars='{"--hw-accent":"#3F8F5F","--hw-radius":"24px"}'
  data-custom-css-id="hatched-widget-css"
  defer
></script>

<script type="text/hatched-css" id="hatched-widget-css">
  .hw-container {
    box-shadow: 0 24px 70px -42px rgba(25, 20, 12, 0.42);
  }

  .hw-market-card {
    border-radius: var(--hw-card-radius);
  }
</script>
```

### Loader styling attributes

| Attribute             | Values                                                                              | Purpose                                      |
| --------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------- |
| `data-surface`        | `paper` `glass` `metal` `crt` `parchment` `vellum`                                  | Shell background texture                     |
| `data-geometry`       | `rounded` `cut` `pill` `sharp` `organic`                                            | Corner radii across shell, cards, buttons    |
| `data-motion-profile` | `calm` `standard` `expressive` `theatrical`                                         | Idle bounce and hover amplitude              |
| `data-reward-voice`   | `quiet` `crisp` `joyful` `epic`                                                     | Celebration intensity                        |
| `data-iconography`    | `geometric` `hand-drawn` `pixel` `embossed` `flat-mono`                             | Icon styling                                 |
| `data-typography`     | `sans-serif-modern` `serif-classic` `mono-tech` `rounded-playful` `display-condensed` | Body + display font pair                     |
| `data-size`           | `small` `medium` `large`                                                            | Widget density and panel height              |
| `data-theme-vars`     | JSON object of `--hw-*` values                                                      | CSS variables injected into each shadow root |
| `data-custom-css`     | CSS string                                                                          | Inline custom CSS for short overrides        |
| `data-custom-css-id`  | element id                                                                          | Reads CSS from a `<script type="text/...">`  |

Prefer variables for colors, radius, spacing mood and shadow. Use custom CSS
for component-level details such as a marketplace card hover, badge tile shape
or action button treatment.

### Supported design tokens

| Token                 | Controls                         |
| --------------------- | -------------------------------- |
| `--hw-bg`             | Widget shell background          |
| `--hw-bg-elevated`    | Raised panel background          |
| `--hw-surface`        | Card and item surfaces           |
| `--hw-surface-muted`  | Muted wells, skeletons, tracks   |
| `--hw-border`         | Default border color             |
| `--hw-border-strong`  | Hover and selected border color  |
| `--hw-text`           | Primary text                     |
| `--hw-text-secondary` | Secondary text                   |
| `--hw-text-muted`     | Captions and metadata            |
| `--hw-accent`         | Primary action and progress      |
| `--hw-accent-strong`  | Action hover and stronger accent |
| `--hw-accent-soft`    | Pills, glow and soft fills       |
| `--hw-positive`       | Success/equipped states          |
| `--hw-positive-soft`  | Soft success backgrounds         |
| `--hw-warn`           | Warning states                   |
| `--hw-radius`         | Outer widget radius              |
| `--hw-card-radius`    | Card and item radius             |
| `--hw-button-radius`  | Button radius                    |
| `--hw-shadow`         | Main widget shadow               |
| `--hw-font-body`      | Body font stack                  |
| `--hw-font-display`   | Playful/display font stack       |

### Stable class hooks

| Hook                | Scope                 | Use for                                   |
| ------------------- | --------------------- | ----------------------------------------- |
| `.hw-container`     | All widgets           | Shell, background, border, radius, shadow |
| `.hw-title`         | All widgets           | Main heading typography                   |
| `.hw-card`          | Buddy, badges, streak | Shared cards and stat panels              |
| `.hw-btn`           | Marketplace, buddy    | Buttons and interactive actions           |
| `.hw-market-card`   | Marketplace           | Item tile surface, spacing and hover      |
| `.hw-market-asset`  | Marketplace           | Item media well                           |
| `.hw-market-rarity` | Marketplace           | Rarity pill                               |
| `.hw-badge-tile`    | Badges, buddy         | Achievement tile shape and earned state   |
| `.hw-streak-root`   | Streak                | Streak panel shell and day strip          |
| `.hw-path-root`     | Path                  | Path panel shell and accent context       |
| `.hw-path-node`     | Path                  | Journey-map and stepper milestone nodes   |
| `.hw-path-substep`  | Path                  | Sub-step row spacing and state styling    |
| `.hw-path-cta`      | Path                  | Manual completion button                  |
| `.hw-lb-context`    | Leaderboard           | Current-user rank context                 |
| `.hw-evo-card`      | Buddy                 | Evolution stage card and progress rail    |
| `.hw-appearance-banner` | Buddy, marketplace | Pending, awaiting credits, and failed appearance states |
| `.hw-modal-note`    | Marketplace           | Purchase/equip confirmation helper copy   |

Do not target generated Preact internals or DOM depth. Hatched treats the
hooks above and `--hw-*` variables as the public customization surface.

Celebrations are API-driven from the loader:

```ts
window.__HATCHED_WIDGET__.celebrate({
  kind: 'badge',
  badge: { key: 'week_warrior', label: 'Week Warrior' },
});
```

## Browser event tracking

If the session includes `events:track`, the host page can send browser-safe
events without exposing a secret API key:

```ts
await window.__HATCHED_WIDGET__.track('lesson_completed', {
  lessonId: 'lesson_1',
});
```

The API derives `user_id`, `buddy_id`, and audience from the session token, so
the browser cannot spoof another user.

## Hatching and evolution from events

The widget displays live Hatched state; it does not replace the backend
progression loop. `events.send()` returns `effects.evolutionReady === true`
when the buddy has met the next stage condition. If the customer config has
`auto_evolve` enabled, Hatched starts the evolution operation automatically.
If `auto_evolve` is disabled, your backend should start it explicitly:

```ts
const effects = await hatched.events.send({
  eventId,
  userId,
  type: 'lesson_completed',
  properties,
});

if (effects.evolutionReady) {
  const op = await hatched.buddies.evolve(buddyId);
  await hatched.operations.wait(op.operationId);
}
```

The widget will pick up the new stage through `/widget/state` after the
operation completes. If the buddy has equipped marketplace items, the stage can
advance before item compositing is fully recovered. In that case `/widget/state`
returns `buddy.appearance.status` plus `pending_operations`; the buddy and
marketplace widgets show a non-blocking appearance banner until the composite is
`ready`, `awaiting_credits`, or `failed`.

## Error states

Every widget renders inside a shared error boundary. When a fetch fails
(`401`/`403`/`429`/`5xx`/network) or a render throws, the boundary swaps the
widget for a small themed fallback instead of a blank Shadow DOM:

- **`401` (token invalid/expired)** — "Session expired" + a Re-authenticate
  button. Re-mounting the widget with a fresh `data-session-token` clears
  the state.
- **`403` (plan/scope)** — "Not available here". Points the user (or your
  ops team) at upgrading the plan or enabling the capability.
- **`429` (rate-limited)** — "Slow down a bit". The widget retries on its
  own once the rate-limit window passes.
- **`5xx` (server)** — "Hatched is having trouble". Includes the
  `x-request-id` so support can correlate with platform logs.
- **Network failure** — "Connection issue" + a Retry button.
- **Unknown render error** — generic "Something went wrong"; the stack is
  logged to the host browser console with the widget name as a prefix.

The fallback is keyboard-accessible (`role="alert"`), respects `var(--hw-*)`
theme tokens, and shows a short `ref:` line carrying the request id when
present. Don't try to suppress it — the failure state is the customer's
signal to act, and the boundary keeps the rest of the page from blowing up.

## Common pitfalls

- **Secret key in browser bundle**: never do this. Mint session/embed tokens on
  your backend, or use a publishable key only for read-only SDK calls.
- **Wrong attribute**: token attributes live on the script tag
  (`data-session-token` or `data-embed-token`), not on the mount div.
- **Wrong global or init shape**: the public global is
  `window.__HATCHED_WIDGET__`, and SPA remounts should call
  `window.__HATCHED_WIDGET__?.init({ token })`. Do not call
  `window.HatchedWidget?.init({ embedToken, userId })`.
- **Missing mount attribute**: new snippets should use
  `data-hatched-mount="buddy"` (or `badges`, `marketplace`, `leaderboard`,
  `streak`, `path`). Legacy ids such as `buddy-widget` are still supported.
- **Origin not allowed**: `/widget/state` returning 403 with
  `Origin "... " is not allowed for widget access` means the browser origin is
  missing from Dashboard → Settings → General → Widget allowed origins. Onboarding
  seeds the pasted website origin automatically, but local/staging app origins
  may still need to be added. If the origin is visible there and the 403
  persists, verify the token was minted for the same Hatched environment and
  customer you edited; token remint is not required for allowlist changes.
- **Expired token**: session tokens should be short-lived. Re-mint on route
  change, focus, or page refresh.
- **Read-only marketplace token**: embed tokens can render catalog, ownership,
  affordability, and the current outfit. Buying and equipping require a widget
  session token with `marketplace:purchase` and `items:equip`.
- **Appearance pending or failed**: equip and evolve can queue an image
  composite. Watch `buddy.appearance.status` from `/widget/state`; for
  `failed` with `error.code === 'needs_rerender'`, call
  `POST /widget/appearance/rerender` with an `items:equip` session, wait for
  `ready`, then let the user re-equip.
