# Integrating Hatched

> Drop this file into your repo as `AGENTS.md` (or paste it into `CLAUDE.md`,
> `.cursorrules`, Copilot instructions, etc.) so your coding assistant knows how
> to use Hatched correctly. Source of truth: https://docs.hatched.live

Hatched is a gamification layer for B2B products. You send product events; it
runs a rule engine that grows "buddies" and awards coins, tokens, badges,
streaks, and marketplace items. It ships a typed TypeScript SDK
(`@hatched/sdk-js`) and embeddable widgets.

## Install

```bash
pnpm add @hatched/sdk-js   # or npm install / yarn add / bun add
```

Dual ESM + CJS. Runs on Node 18+, Cloudflare Workers, Vercel Edge, Deno, Bun.

## Server client

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

const hatched = new HatchedClient({
  apiKey: process.env.HATCHED_API_KEY!, // hatch_live_* (prod) or hatch_test_* (staging)
});
```

`hatch_test_*` keys default to the staging API; `hatch_live_*` keys default to
production. Override with `baseUrl` if needed.

> **Casing:** the raw HTTP API uses `snake_case` (`user_id`, `buddy_id`,
> `ttl_seconds`) — sending camelCase is rejected (`property userId should not
> exist`). The SDK is the only place you write camelCase; it converts on the
> wire. Only call the raw API if there's no SDK for your language.

> **Prerequisite:** the customer must have a **published config version** before
> any buddy can be created. Picking a dashboard preset during onboarding publishes
> the first one automatically; configs built from scratch (and later edits) need a
> manual publish, otherwise `POST /eggs` → `409 no_published_config` /
> `NoPublishedConfigError` (`err.details.publish_url` links to the dashboard
> publish page). Also: streaks/paths/badges/coin-rules/marketplace items only
> reach widgets once they're in the *published* snapshot, and existing buddies
> stay pinned to their old config version until migrated.

## The cardinal rule: secret keys are server-only

`hatch_live_*` and `hatch_test_*` are **secret keys**. They go in server-side
env vars only. The SDK throws if you instantiate it in a DOM environment — do
not try to work around that, and never ship a secret key in a browser bundle,
mobile app, or anything a user can inspect.

For the browser you have two safe options:

- **Widget session token** — mint a short-lived, scoped token on your server and
  hand it to the client:
  ```ts
  const session = await hatched.widgetSessions.create({
    buddyId,
    userId,
    scopes: ['read', 'events:track', 'marketplace:browse'],
    ttlSeconds: 60 * 15,
  });
  // send session.token to the browser
  ```
- **Publishable key** (`hatch_pk_*`) — for limited read-only client use:
  `new HatchedClient({ publishableKey: process.env.NEXT_PUBLIC_HATCHED_PK! })`.

See https://docs.hatched.live/docs/concepts/auth-model for the decision tree.

## Bootstrapping a buddy — do this once per user

A widget token is scoped to a `buddyId`; you can't go from `userId` straight to a
session token. **Reuse an existing buddy before creating an egg** — React Strict
Mode, focus re-fetches, hot reloads, and retries will otherwise create duplicate
eggs until you hit the per-user cap (`409 active_egg_limit` / `ActiveEggLimitError`;
`err.details.active` lists the eggs you already have). `eggs.create({ ..., ensure: true })`
reuses an existing `waiting`/`ready` egg instead of creating another.

```ts
async function ensureBuddyId(userId: string): Promise<string> {
  // 1. Stored against this app user? Use it. (DB column for auth apps; localStorage for demos.)
  const stored = await loadStoredBuddyId(userId);
  if (stored) return stored;

  // 2. Hatched already has a buddy for this user? Reuse it.
  const existing = await hatched.buddies.list({ userId, status: 'active' });
  if (existing.data.length > 0) {
    await saveStoredBuddyId(userId, existing.data[0].buddyId);
    return existing.data[0].buddyId;
  }

  // 3. None yet — create one. GUARD this so it runs at most once per user
  //    (in-flight map / DB unique constraint); never on every mount/focus/HMR.
  //    ensure:true → reuse a half-finished egg from a crashed attempt instead of
  //    piling up new ones.
  const egg = await hatched.eggs.create({ userId, ensure: true });
  if (egg.status === 'waiting') await hatched.eggs.updateStatus(egg.eggId, 'ready');
  const op = await hatched.eggs.hatch(egg.eggId);
  // Hatch is async (5–45s) — poll, show a loading state, don't block the UI.
  const result = await hatched.operations.wait(op.operationId, { intervalMs: 2_000 });
  if (result.status !== 'completed') throw new Error(`hatch ${result.status}`);
  const buddyId = result.result.buddyId; // persist this immediately
  await saveStoredBuddyId(userId, buddyId);
  return buddyId;
}
```

(`GET /eggs/:id` and `GET /eggs` also include `buddy_id` once the egg is hatched —
handy if you ever lose the operation result.)

Then mint a widget session token for that buddy (server-side) and pass `token`
to the browser:

```ts
const session = await hatched.widgetSessions.create({
  buddyId,
  userId,
  scopes: ['read', 'events:track', 'marketplace:browse', 'marketplace:purchase', 'items:equip'],
  ttlSeconds: 3600,
});
```

`POST /widget-sessions` (interactive, needs `scopes`, used with
`data-session-token`) is **not** `POST /embed-tokens` (read-only, rejects
`scopes`, used with `data-embed-token`). Full flow + raw-HTTP version:
https://docs.hatched.live/docs/guides/first-user-bootstrap

## Core flows (once the buddy exists)

```ts
// Teach the buddy about your product (idempotent — always pass a stable eventId)
await hatched.events.send({
  eventId: `lesson_1:${userId}`, // dedupe key; resending is a no-op
  userId,
  type: 'lesson_completed',
  properties: { score: 94 },
});

// Economy
await hatched.buddies.earn(buddyId, { amount: 50, reason: 'lesson_reward' });
await hatched.buddies.spend(buddyId, { amount: 20, reason: 'item_purchase' });
const equip = await hatched.buddies.equip(buddyId, { equip: [itemId] });
if (equip.operationId) await hatched.operations.wait(equip.operationId);
const buddy = await hatched.buddies.get(buddyId);

// Gates (spend the primary token to unlock content)
await hatched.gates.unlock(buddyId, 'advanced_mode');
```

`type` on `events.send` is whatever string makes sense for your product —
unknown types are stored as custom counters, never dropped.

## Webhooks

Verify the HMAC signature against the **raw request body** before parsing JSON.
The signature arrives in the `Hatched-Signature` header, format
`t=<unix_ts>,v1=<hmac_sha256_hex>`.

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

// Next.js route handler
export async function POST(req: Request) {
  const rawBody = await req.text();
  const signature = req.headers.get('hatched-signature') ?? '';

  const ok = WebhooksResource.verifySignature(
    rawBody,                            // the raw bytes/string, NOT the parsed object
    signature,
    process.env.HATCHED_WEBHOOK_SECRET!,
  );
  if (!ok) return new Response('invalid signature', { status: 400 });

  const event = JSON.parse(rawBody);
  // dedupe on event.deliveryId, then handle
  return new Response(null, { status: 202 });
}
```

In Express, configure `express.raw({ type: 'application/json' })` on the webhook
route so `req.body` is a Buffer. Dedupe on the payload's `deliveryId`. Payload
shapes: https://docs.hatched.live/docs/reference/webhook-payloads

## Errors

The SDK throws typed subclasses of `HatchedError`, each with `code`,
`statusCode`, and `requestId`:

```ts
import { HatchedError, RateLimitError, InsufficientBalanceError } from '@hatched/sdk-js';

try {
  await hatched.buddies.spend(buddyId, { amount: 999999, reason: 'x' });
} catch (err) {
  if (err instanceof InsufficientBalanceError) { /* show "not enough coins" */ }
  else if (err instanceof RateLimitError) { /* back off */ }
  else if (err instanceof HatchedError) { console.error(err.code, err.requestId); }
  else throw err;
}
```

Full list: https://docs.hatched.live/docs/reference/error-codes

## Widgets

Embed display surfaces with one loader script plus `data-hatched-mount` elements
(they render inside a Shadow DOM, so host-page CSS can't leak in):

```html
<script
  src="https://cdn.hatched.live/widget.js"
  data-session-token="WIDGET_SESSION_TOKEN"
  defer
></script>
<div data-hatched-mount="buddy"></div>
<div data-hatched-mount="streak" data-streak-key="daily_lesson"></div>
<div data-hatched-mount="badges"></div>
<div data-hatched-mount="marketplace"></div>
<div data-hatched-mount="leaderboard"></div>
```

`data-session-token` is the widget session token you minted server-side (or
`data-embed-token` for a read-only mount) — never a secret key. The mount
`<div>`s carry `data-*` config the loader manages; you only write
`data-hatched-mount` (and, for streak/path, the key). Widget reference:
https://docs.hatched.live/docs/reference/widgets

## Where to look

- Full docs, machine-readable: https://docs.hatched.live/llms-full.txt
- Page index: https://docs.hatched.live/llms.txt
- Any page as Markdown: `https://docs.hatched.live/llm/<path>` —
  e.g. https://docs.hatched.live/llm/guides/getting-started
- SDK reference: https://docs.hatched.live/docs/reference/sdk-js

## Don't

- ❌ Put a `hatch_live_*` / `hatch_test_*` key in client code, a bundle, or a
  `NEXT_PUBLIC_*` var. Use a widget session token or a `hatch_pk_*` key.
- ❌ Create an egg on every app mount / focus / hot reload / failed widget mount.
  Reuse a stored `buddyId`, then `GET /buddies?user_id=…`, then (guarded) create.
- ❌ Pass a `userId` where a `buddyId` is expected (`widget-sessions`, `gates`,
  `buddies.*`). `buddyId` comes from the hatch operation's `result`.
- ❌ Call `hatched.events.send` without a stable `eventId` — you'll double-count.
- ❌ Send camelCase to the raw HTTP API — it's `snake_case`. (The SDK converts.)
- ❌ Parse the webhook JSON before verifying the signature.
- ❌ Treat hatch (or evolve / equip) as synchronous — they return an
  `operationId`; use `operations.wait(operationId)`, don't tight-loop `operations.get`.
- ❌ Hand-roll HTTP calls when an SDK method exists.
