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.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add mislavjc/stoop --skill stoop-platform --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Stoop Platform?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/mislavjc-stoop-platform)More formats (shields.io, HTML) on the badges page.
---
name: stoop-platform
description: "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."
---
# 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>
```
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!