# Stoop Platform

> The stoop platform SDK reference: `platform.db`, `identity`, `ai`, `files`, `channel`, and the served house-style theme. Load before writing any `platform.*` call in a stoop site, whichever way the site is published.

- Skill: `mislavjc/stoop-platform` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mislavjc/stoop-platform`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mislavjc/stoop-platform/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: mislavjc (https://skillmd.com/u/mislavjc)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mislavjc/stoop-platform

---


# The stoop platform SDK

What a stoop site can do at runtime without any backend code of its own.
This is the same surface however the site was published — from a folder with
the `stoop` CLI, or over HTTP with an API key.

## Loading it

Every page that needs data or identity loads the SDK. It is served by the
platform on the site's own origin — do not bundle, vendor, or CDN it:

```html
<script src="/__platform/sdk.js"></script>
```

It installs `window.platform`.

## Database — `platform.db.collection(name)`

Schemaless JSON documents, shared by all visitors of the site, with
realtime updates:

```js
const votes = platform.db.collection("votes");
await votes.create({ spot: "Taco Cart" }); // → doc; the server assigns doc.id
await votes.list();                        // → doc[] (newest first)
await votes.get(id);                       // → doc
await votes.update(id, { count: 2 });      // → doc (shallow merge)
await votes.delete(id);                    // → null
const stop = votes.subscribe({             // realtime; returns unsubscribe fn
  onCreate: (doc) => {},
  onUpdate: (doc) => {},
  onDelete: (id) => {},
});

// Queries: equality filters, one sort field (- prefix = descending;
// _created / _updated sort by timestamp), and a page-size limit.
await votes.list({ where: { spot: "Taco Cart" }, sort: "-_created", limit: 50 });
// Pagination for big collections: cursor is null on the last page.
const { docs, cursor } = await votes.page({ limit: 100 });
const rest = await votes.page({ limit: 100, cursor });

// Access rules — make a collection read-only or invisible to visitors.
// Only site members may call this ("public"/"public" is the default).
await votes.setRules({ read: "public", write: "members" });
```

- Subscribe events fire for every client's writes, including this page's
  own: render from `list()` + subscribe events and don't also apply local
  writes to the UI, or they will show up twice.
- `list()` returns newest first — reverse it when appending rows in
  chronological order, so live `onCreate` appends continue the same order.
- `list()` returns at most one page (1000 docs); use `page()` and follow
  `cursor` when a collection can grow past that.
- `where` is exact equality on top-level fields only (no ranges, no
  substring match) — filter client-side for anything richer.
- No transactions or atomic increments: concurrent `update()`s to the same
  key are last-write-wins, so a shared counter can drop clicks. When every
  event must count (votes, tallies), `create()` one document per event and
  count them client-side.
- Documents may carry a platform-stamped `_by` author (`{ id, name }`).
  Client-sent values are ignored, and anonymous visitors cannot edit or
  delete documents authored by someone else.
- `setRules({ read, write })` with `"public" | "members"` persists per
  collection. Use `write: "members"` for owner-curated content (menus,
  announcements) that visitors read but must not change; visitor sites
  calling it get a 403, so gate it on user action, not page load.

## Identity — `platform.identity.me()`

```js
const me = await platform.identity.me(); // → { id, name, anonymous }
```

Every visitor gets a stable per-site identity — use it for per-user
features (one vote per person, authorship labels, presence) with no login.

## AI — `platform.ai`

Server-side Workers AI with no client keys:

```js
const text = await platform.ai.chat("Suggest a taco topping"); // → string
const text2 = await platform.ai.chat([                          // full turns
  { role: "system", content: "Answer in one sentence." },
  { role: "user", content: "Why stoops?" },
]);
// Stream tokens as they arrive; still resolves to the full text.
const full = await platform.ai.chat("Write a haiku", {
  onToken: (t) => (output.textContent += t),
});
const img_blob = await platform.ai.image("a fox on a stoop"); // → image Blob
img.src = URL.createObjectURL(img_blob);
```

- Calls are metered per site and per visitor per day; a 429 means the
  daily limit is reached. Trigger AI on explicit user action — never
  automatically, in loops, or per keystroke.
- Quota and availability errors reject the promise — wrap calls in
  try/catch and degrade gracefully (the error `message` is showable).
- Image generation is slow (seconds) and has much tighter limits than
  chat; design around few, deliberate generations.

## Files — `platform.files`

Visitor file uploads stored by the platform, served from the site's own
origin:

```js
const up = await platform.files.upload(file); // File/Blob → { id, url, contentType, size }
img.src = up.url;                             // e.g. /api/files/<id>
await platform.files.list();                  // → [{ id, url, contentType, size, uploadedAt }]
await platform.files.delete(up.id);           // → { ok: true }
```

- Ownership mirrors the database: anonymous visitors can delete only
  their own uploads; a delete of someone else's file fails with 403.
- Limits: 2 MB per file and 50 files on an unclaimed (temporary) site;
  10 MB and 2000 files once claimed. Over-limit uploads fail with a
  message naming the limit.
- Media types (images, video, audio, PDF, plain text) render inline from
  `url`; every other type is forced to download. Use files for user
  media — never to serve extra pages or scripts.

## Realtime channels — `platform.channel`

Ephemeral broadcast between the visitors on the site right now — live
cursors, presence, typing indicators. Nothing is stored:

```js
const ch = platform.channel.join("cursors", {
  onPeers: (peers) => {},        // roster on connect: [{ id, name }]
  onJoin: (peer) => {},          // someone arrived
  onLeave: (peer) => {},         // someone left
  onMessage: (data, from) => {}, // another peer's send(); from = { id, name }
});
ch.send({ x: 10, y: 20 }); // broadcast to everyone else (never echoed back)
ch.leave();
```

- Ephemeral by design: missed messages are gone; anything that must
  survive a reload belongs in `platform.db`.
- Messages are JSON, capped at 8 KB.
- Peers are visitor identities, not connections: a visitor's other tabs
  and devices share one identity and never receive that visitor's sends,
  joins, or roster entries. Demo/test with two different browsers (or one
  normal + one incognito window), never two tabs of the same browser.
- `send()` while the socket is connecting or reconnecting is dropped
  silently — tie sends to ongoing user activity (cursor moves, typing)
  rather than one-shot moments, so the next event heals the gap.
- WebSocket-only. In local dev previews channels cannot connect (the SDK
  warns once in the console and keeps retrying) — design the site to
  still work without channel connectivity.

## House style — `/__platform/theme.css`

The platform serves a design-system stylesheet on every site's origin.
Link it before your own `<style>` and plain HTML lands in the stoop house
style (light mode): centered 640px column on the canvas color, styled
headings, buttons, and inputs with hover/focus states:

```html
<link rel="stylesheet" href="/__platform/theme.css">
```

- Component classes: `.card`, `.btn` (for links styled as buttons),
  `.btn-danger`, `.btn-ghost`, `.field` (label + input stack),
  `.badge`, `.empty` (friendly empty state), `.subtle`.
- For custom colors and spacing use the theme tokens —
  `var(--color-kumo-brand)`, `var(--color-kumo-line)`,
  `var(--text-color-kumo-subtle)`, `var(--space-4)`,
  `var(--radius-card)`, … — not hex colors, so the site stays coherent.
- Write CSS only for page-specific layout; don't restyle what the theme
  already styles. Full-bleed layouts may override the body max-width.

## Constraints

- `/api/*` and `/__platform/*` URL paths
  are reserved by the platform router; files published there will never be
  served.
- A site needs `index.html` at its root, or its `/` URL 404s.
- Static only: no server-side code, no environment variables, no secrets. If
  a feature needs more than static files plus the SDK above, it does not fit
  stoop.

## Worked example — realtime voting

```html
<!doctype html>
<html>
  <head>
    <link rel="stylesheet" href="/__platform/theme.css">
  </head>
  <body>
    <h1>Lunch spots</h1>
    <ul id="spots"></ul>
    <button id="add">Add Taco Cart</button>
    <script src="/__platform/sdk.js"></script>
    <script>
      const votes = platform.db.collection("votes");
      const list = document.getElementById("spots");
      const rows = new Map();   // spot → li
      const counts = new Map(); // spot → n

      // One document per vote (safe under concurrent voters); counts are
      // derived client-side from list() + live onCreate events.
      const bump = (spot) => {
        counts.set(spot, (counts.get(spot) ?? 0) + 1);
        let li = rows.get(spot);
        if (!li) {
          li = list.appendChild(document.createElement("li"));
          li.onclick = () => votes.create({ spot });
          rows.set(spot, li);
        }
        li.textContent = spot + ": " + counts.get(spot);
      };

      votes.list().then((docs) => docs.reverse().forEach((d) => bump(d.spot))); // list() is newest first
      votes.subscribe({ onCreate: (d) => bump(d.spot) });
      document.getElementById("add").onclick = () => votes.create({ spot: "Taco Cart" });
    </script>
  </body>
</html>
```

