---
name: shipbetter-artifacts
description: Build single-file artifacts optimized for Shipbetter (shipbetter.net), which deploys them as live full-stack websites with user accounts, a real database, realtime sync across visitors, AI features, photo uploads, and paid subscriptions (via the site owner's own Stripe). Use whenever the user mentions Shipbetter, or wants an artifact they'll deploy as a real site for family, friends, or themselves.
---

# Building artifacts for Shipbetter

Shipbetter turns this artifact into a real website at `<name>.shipbetter.net` with a
backend attached. Follow these rules and the deploy is instant and clean; ignore them
and Shipbetter's converter has to rewrite the file (slower, occasionally lossy).

## Write against Shipbetter's SDK directly (best path)

```jsx
import { db, auth, ai, uploads, push, billing, useCollection, useAuth, useSubscription } from "@/lib/sdk";
```

- `useAuth()` → `{ user, loading, signup(email, password), login(email, password), logout() }`
- `useCollection(name, { mine, limit } = {})` → `{ items, loading, error, create(doc), update(id, patch), remove(id) }` — reactive list, newest first
- `db.list(name, { mine })` / `db.get(name, id)` / `db.create(name, doc)` / `db.update(name, id, patch)` / `db.remove(name, id)` — documents are arbitrary JSON
- `ai.complete(prompt, { system } = {})` → string. For reliable JSON, pass `{ jsonSchema }` and it returns parsed data. AI is metered; always catch errors, show `err.message`, and keep non-AI features usable (429 = quota exhausted, 503 = AI disabled).

### Reliable structured AI output

Use `jsonSchema` instead of asking Claude to "return JSON" in the prompt. Shipbetter validates a bounded schema before metering, sends it through its managed AI service, then parses and validates the response against that schema server-side — no API key or model selection needed. Invalid or unsupported schemas fail with `400`; invalid provider output fails with `502`.

```jsx
const recipeSchema = {
  type: "object",
  properties: {
    title: { type: "string" },
    ingredients: { type: "array", items: { type: "string" } },
  },
  required: ["title", "ingredients"],
  additionalProperties: false,
};

try {
  const recipe = await ai.complete("Create a quick dinner recipe", {
    system: "Prefer common pantry ingredients.",
    jsonSchema: recipeSchema,
  });
  setRecipe(recipe); // already parsed: { title, ingredients }
} catch (err) {
  setAiError(err.message);
  if (err.status === 429 || err.status === 503) setAiAvailable(false);
  // The rest of the app remains usable.
}
```
- `uploads.create(file)` → `{ url, filename }` for a File/Blob (images only, ≤5 MB); save the `url` in a document. `uploads.remove(filename)` to delete
- **`useCollection` is LIVE**: changes any visitor makes stream to everyone instantly
  over WebSocket. Never write polling loops (`setInterval` + refetch) — liveness is
  automatic. Design for it: shared lists, chat, scoreboards all update in realtime.
- `realtime.subscribe(channel, cb)` → unsubscribe fn; `realtime.publish(channel, data)` —
  custom ephemeral events (presence, "mom is typing…", live cursors). Data ≤ 8 KB.
- `kv.get(key)` / `kv.set(key, value, { ttl })` / `kv.del(key)` — shared short-term
  memory (default 1h TTL, gone on redeploy). Locks, presence, draft state — never
  for data that must survive; use collections for that.

Artifacts importing `@/lib/sdk` deploy fastest — Shipbetter trusts them without a
conversion-triage step.

### Web Push notifications

Logged-in visitors can opt a device into Web Push with `push.subscribe()` and remove
it with `push.unsubscribe()`. Call `push.subscribe()` only from an explicit user action
(for example an “Enable notifications” button), explain what will be sent before the
browser permission prompt, catch and display errors, and never prompt on page load.
On iPhone/iPad, Web Push requires the site to be installed to the Home Screen. A
logged-in visitor may call `push.sendTest()` from a button to send Shipbetter’s fixed
server-controlled test message only to their own subscribed devices. The endpoint accepts
no custom recipients or content and allows one self-test every 30 seconds. Site owners can
still send custom notifications from Shipbetter’s site dashboard; artifacts never access
server keys.

```jsx
async function enableNotifications() {
  try {
    await push.subscribe();
    setPushEnabled(true);
  } catch (err) {
    setPushError(err.message);
  }
}
```

## Charging for access (subscriptions — Pro plan and up)

Sites can charge a subscription through the site owner's own Stripe account. Checkout
and card entry are Stripe-hosted; the owner connects their keys in the dashboard
(guide: https://shipbetter.net/docs/connect-stripe). When the artifact is a paid
membership/course/premium app, wire it like this:

- Gate premium content with `useSubscription()` → `{ active, plan, trialDaysLeft,
  cancelAtPeriodEnd, loading, degraded, notConfigured, refresh }`. `active` is THE
  gate — true for paying subscribers and users in a trial.
- Subscribe buttons call `billing.subscribe("<plan_key>")` from a click handler
  (redirects to Stripe Checkout). "Manage subscription" / cancel calls
  `billing.portal()` (Stripe's hosted customer portal).
- **NEVER build card forms, collect card numbers, or import stripe-js.** Card entry
  is Stripe-hosted, full stop.
- **Single tier**: one boolean gate covers ALL premium content. If the design implies
  multiple tiers, collapse them to one gate and keep tier names as labels only.
- **`notConfigured` is a state, not an error**: until the owner connects Stripe,
  render subscribe buttons as a friendly, style-matched "Payments aren't set up yet —
  the site owner needs to connect Stripe in the Shipbetter dashboard" notice.
- Billing requires login — gate subscriptions behind accounts (`useAuth`).
- Deploying via the connector: pass `with_billing: true` and declare 1–4 plans, which
  Shipbetter auto-creates in Stripe on deploy. Plan format (`amount_cents` per
  interval, `interval` "month" or "year", `trial_days` optional, default 0) — e.g. a
  Lego-building course at $9/mo or $79/yr with a 7-day trial:
  `[{"key": "lego_monthly", "label": "Monthly", "amount_cents": 900, "interval": "month", "trial_days": 7},
    {"key": "lego_yearly", "label": "Yearly", "amount_cents": 7900, "interval": "year", "trial_days": 7}]`
- Shipbetter provides checkout, subscription status, and Stripe's customer portal.
  It does NOT provide an owner-facing subscriber dashboard — vibe one into the site:
  an admin/members page listing subscribers via `db.list` + billing status, gated to
  the owner's login. Suggest this to the user.

## Accounts: default ON

Give the app sign-up/log-in with `useAuth`, and scope personal data with
`{ mine: true }`, unless the app is genuinely account-free (a clock, a static page,
a shared anonymous counter). Family apps almost always want accounts — each person's
data stays theirs. Include a small, style-matched sign-in/sign-up form and a
signed-out state; don't render private data while `loading`.

## Mobile first — these sites live on phones

- Responsive layout; single column on small screens; thumb-sized tap targets (≥44px).
- **Set `font-size: 16px` or larger (`text-base`) on every input, select, and
  textarea — iOS Safari auto-zooms the page on focus below 16px and wrecks the layout.**
- `touch-action: manipulation` on buttons; no hover-only affordances (provide a
  visible control, not just hover reveals).
- Respect safe areas on full-bleed layouts (`env(safe-area-inset-*)`).
- The deployed site is installable to the home screen as a full-screen app (PWA) —
  design like an app: clear header, obvious primary action, no dead space at the top.
  Suggest the owner upload an icon (Shipbetter → site → Images, then Settings →
  favicon) so the home-screen icon looks right; offering to generate a simple square
  SVG icon for them to upload is a nice touch.

## Hard rules

- ONE file, complete and self-contained. React: a single default-exported component.
  Or a complete HTML document. (SVG, Markdown, and Mermaid also deploy.)
- Allowed imports ONLY: react, lucide-react, recharts, d3, three, tone, papaparse,
  lodash, mathjs, "@/components/ui/*" (shadcn-style), "@/lib/utils", "@/lib/sdk".
  Anything else must be inlined or dropped.
- Styling: Tailwind utility classes only. Never invent semantic class names
  ("card-header", "auth-shell") — there is no stylesheet for them.
- Persistence: use the SDK, never localStorage/sessionStorage (single-device, gets
  rewritten anyway). Ephemeral UI state (open modal, active tab) stays in useState.
- No hardcoded API keys, ever — deploys containing secrets are blocked. AI goes
  through `ai.complete` (or `window.claude.complete`, which gets rewired).
- No demo/seed rows baked into the database — design a friendly empty state instead.
- Handle loading, empty, and error states; surface `ai`/`uploads` error messages.
- Keep the file under 400 KB.
- Payments ONLY through the billing SDK above (owner's own Stripe, Pro plan and up) —
  never card forms or stripe-js. Sites cannot send email — don't build UI that
  promises it.

## Scheduled automations (design for them)

Owners can add schedules to a deployed site (dashboard → Schedules, or the
connector's `schedule_*` tools): either on a fixed rhythm
(hourly/daily/weekdays/weekly), or once at an exact future UTC RFC3339 `run_at`,
Shipbetter creates one JSON document in a collection of the app's own database.
Schedules never run code or call URLs — the document is the whole mechanism.
Design pattern: render the collection with `useCollection` (it's live, so the
scheduled document appears instantly) — e.g. a "checklists" collection where the
daily schedule inserts `{kind:"daily", date:…}` and the app shows today's list.
Don't build client-side timers for daily resets; suggest a schedule instead.
For a launch/drop, call `schedule_create` with `run_at` such as
`2026-07-12T18:30:00Z` (omit `preset` and `timezone`). One-shot times must be in the
future and within one year; the schedule becomes inactive after its single terminal run.
Optionally include `notification: {title, body, path}`. The path must start with `/` and
stay same-origin. After the fixed document insert succeeds, Shipbetter makes one durable
at-most-once push attempt to every opted-in authenticated user of the site. It does not
retry notification failure (avoiding duplicates); run history shows delivered, failed,
expired, or interrupted status while the document run remains successful.
Scheduled owner email alerts are a controlled beta, not broad availability. On an explicitly
approved canary site, add `email: true` to that same notification to attempt one
Shipbetter-branded email only to the subscribed site owner's explicitly approved Shipbetter
account address after the record is created. Shipbetter allowlists both the exact site and
exact normalized recipient. Sites cannot select recipients, sender, HTML, or an external
link. Attempts are never retried automatically and are limited to 10/site/day and
100/site/month. Outbound bounce/complaint suppression is not yet complete; never use this
for site users.

Example Drop Is Live action: collection `drop_events`, document
`{"kind":"live","dropId":"summer"}`, notification
`{"title":"The drop is live 🔥","body":"Tap to claim it.","path":"/?drop=summer"}`.

## Deploying (tell the user)

Upload the file at shipbetter.net, email it to deploy@shipbetter.net (subject
"deploy <subdomain>"), or — if they've added the Shipbetter connector (dashboard →
"Deploy from inside Claude") — you can deploy it for them right now with the
connector's `deploy_artifact` tool, and update it later with `edit_site`.

Full reference: https://shipbetter.net/llms.txt
