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:
<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:
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 liveonCreateappends continue the same order.list()returns at most one page (1000 docs); usepage()and followcursorwhen a collection can grow past that.whereis 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
_byauthor ({ 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. Usewrite: "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()
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:
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
messageis 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:
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:
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:
<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.htmlat 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
<!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>