# Backyard Backyard turns a **folder of static files** into an internal URL that only people in the company can open (`https://.test.maniflex.dev/`, or `https://test.maniflex.dev/s//` on path-mode servers). Every site gets a zero-config backend through one script tag — `backyard.db` (JSON documents + realtime), `backyard.files` (uploads), `backyard.ai` (LLMs, images, embeddings), `backyard.warehouse` (read-only SQL), `backyard.socket` (rooms with presence) and `backyard.id` (who is viewing). Sign-in is handled by the company identity provider before the page loads. There is nothing else: no server code, no cron, no config files — saying no is a feature. The hub (`https://test.maniflex.dev`) lists every site and serves these docs. Throughout these docs, `https://test.maniflex.dev` stands for the company's Backyard server; `backyard whoami` (or the project's `CLAUDE.md` / `AGENTS.md`) shows its real URL — use that one with people. ## Golden rules 1. **A site is a folder of static files.** `index.html` is the entry point; any HTML, CSS, JS, images, fonts, JSON or WASM next to it is served as-is. A build step is fine — deploy its output folder (`backyard deploy dist`). Clean URLs work (`/about` serves `about.html`); `200.html` is an SPA fallback, `404.html` a custom not-found page. 2. **Include the SDK with an absolute path, before your code:** `` (classic, defines `window.backyard`) or `import backyard from "/__backyard/backyard.mjs"`. Reference **your own** files with **relative** URLs (`app.js`, `img/logo.png`), never `/app.js` — root-absolute paths break on path-mode servers. 3. **No server code.** No Node/Python/PHP backends, API routes, cron jobs or databases of your own. Use the built-in APIs; if something truly needs a backend, tell the user it is out of scope for Backyard. 4. **Never put secrets in the client.** No API keys, tokens or passwords in any file: everything you deploy is readable by every employee. AI and warehouse credentials live on the server; the page never needs a key. 5. **All data of a site is shared by all its users.** Anyone who can open the site can read and write its collections, files and rooms; there are no per-document permissions. Use `_meta.created_by` to show "mine" or to hide edit buttons, but treat it as UX, not security. Do not store confidential data unless the whole company may see it. 6. **Identity is free.** `await backyard.me()` returns the signed-in person (name, email, avatar…). Never build login forms or ask for names. 7. **Deploy = overwrite.** `backyard deploy` replaces the whole site with the folder (files missing locally are deleted). Data (db, uploads) survives deploys. Pick a distinctive site name and confirm with the user before deploying over a site you did not create. 8. **Prefer realtime.** Render lists with `collection.watch(...)` so every open tab updates live; use rooms for presence and high-frequency signals (cursors, typing). 9. **Self-contained pages.** Vanilla HTML/CSS/JS works best. Vendor libraries into the folder instead of loading them from public CDNs (corporate networks may block them). Handle loading, empty and error states, and make it look good on laptop and phone. ## Quickstart ```sh backyard init lunch-vote # ./lunch-vote: index.html, .backyard.json, CLAUDE.md, AGENTS.md, skill cd lunch-vote backyard dev # http://lunch-vote.localhost:4321/ with a local backend (edits show on reload) backyard deploy # publishes the folder and prints the URL ``` - `backyard deploy` needs a login once: `backyard login --server https://test.maniflex.dev` opens the browser (company SSO). An agent cannot complete it — ask the user to run it. - No Backyard server yet? `backyard cloudflare setup` deploys one to the user's own Cloudflare account on the **free plan** (needs a Cloudflare account and Node.js 22+; the user completes a browser login in their terminal) and logs the CLI in, so `backyard deploy` works right after. A release build of the CLI (`install.sh`) brings its engine; one from `go install` or a source build needs a checkout of the Backyard repository or `--package`. Never ask for Cloudflare tokens in chat. See [references/deploy.md](https://test.maniflex.dev/docs/deploy.md#cloudflare-edition-free-tier). - The site name comes from `--site`, `.backyard.json` (`{"site": "lunch-vote"}`) or the folder name. Names: lowercase letters, digits and `-`, ≤ 63 chars, not reserved (`www`, `api`, `docs`, `admin`…). - Useful: `backyard sites list [filter]`, `backyard sites info `, `backyard open [site]`, `backyard pull [dir]` (download a site to study or fork it), `backyard whoami`, `backyard deploy --dry-run`. - With the MCP server (`backyard mcp`) the same is available as tools: `backyard_docs`, `backyard_init`, `backyard_deploy`, `backyard_list_sites`, `backyard_site_files`, `backyard_read_site_file`, `backyard_whoami`, `backyard_open_url`. - `backyard dev` runs until stopped — agents start it in the background with `--no-open` (it opens a browser otherwise). AI and warehouse need `backyard dev --config backyard.yaml` with providers configured; otherwise those calls fail with `unsupported`, so code must degrade gracefully. ### Agent workflow 1. Restate the idea in one line; pick a site name; check it is free with `backyard sites info ` ("not found" = free) or `backyard_list_sites`. 2. `backyard init ` (or create the folder with `index.html` + `.backyard.json`). 3. Build the page(s) following the rules above; keep data models simple (see Patterns). 4. Preview with `backyard dev` when possible; fix console errors. 5. `backyard deploy` from the folder; give the user the printed URL. Redeploy after changes. ## Page skeleton ```html Lunch Vote
Loading…
``` ## API cheat sheet All methods return Promises unless marked *sync*. Errors reject with a `BackyardError` (`err.name === "BackyardError"`, `err.code`, `err.status`, `err.message`, `err.loginUrl?`). On `401` the SDK sends the page to the login screen (`backyard.configure({autoLogin:false})` to handle it yourself). Full details: [references/api.md](https://test.maniflex.dev/docs/api.md). ```js // Context & identity backyard.site // sync {name, url, apiBase, mode: "host"|"path"} await backyard.me() // {id, email, name, title?, team?, slack?, avatar_url?, groups, anonymous, admin, extra?} await backyard.id.lookup("bob@acme.com") // User | null // Database: JSON documents per site; collections appear on first write const todos = backyard.db.collection("todos") // sync const doc = await todos.create({ text: "Hi", done: false }) // {id, text, done, _meta:{created_at, updated_at, created_by, updated_by, version}} await todos.create({ text: "x" }, { id: "custom-id" }) // rejects "conflict" if the id exists await todos.get(id) // Doc | null await todos.set(id, data) // create or replace await todos.update(id, { done: true, note: null }) // shallow merge; null deletes a key; "not_found" if missing await todos.delete(id) // ok even if missing await todos.list({ limit: 50, offset: 0, orderBy: "_meta.created_at", order: "desc" }) // Doc[] await todos.where("done", "==", false).where("tags", "contains", "ops") .orderBy("due", "asc").limit(20).offset(0).get() // Doc[]; .first() → Doc|null await todos.find({ where: [{ field: "done", op: "==", value: false }], order_by: [{ field: "due", dir: "asc" }], limit: 20 }) const stop = todos.watch(docs => render(docs), todos.where("done", "==", false)) // live query; stop() const unsub = todos.subscribe({ onCreate(doc){}, onUpdate(doc){}, onDelete(id){}, onChange(ev){} }) await backyard.db.collections() // string[] // ops: == != < <= > >= in not-in (array ≤ 30) contains (array field has value) // type-strict: "3" never matches 3, a missing field never matches, `!= null` matches nothing // Files const f = await backyard.files.upload(fileOrBlob, { name: "photo.png" }) // {id, name, url, size, content_type, created_at, created_by} img.src = f.url // site-relative URL, works in //`. Uploaded files are visible to everyone who can open the site. Only passive types (images, audio, video, plain text, CSV, PDF, JSON) are served as they are; anything else — HTML, SVG, XML, scripts, unknown types — gets `Content-Security-Policy: sandbox`, so it displays but never runs scripts as your site. Host pages as site files instead. Files have no realtime feed: to show other people's uploads live, also create a db document per upload and `watch` that collection. ```js input.onchange = async () => { const info = await backyard.files.upload(input.files[0]); preview.src = info.url; }; // From a canvas: canvas.toBlob(b => backyard.files.upload(b, { name: "drawing.png" })); ``` ## AI Model names are **aliases** configured by Backyard admins (e.g. `smart`, `fast`, `image`, `embed`); omit `model` to use the server default. List them with `backyard.ai.models()`. Requests are proxied by the server with its own credentials, rate limited per user (default 20 calls/min, 2M tokens/day). When AI is not configured, calls reject with `unsupported`. ### chat ```js const res = await backyard.ai.chat(input, { model: "smart", // optional alias system: "You are a helpful assistant for the Platform team.", maxTokens: 1024, // optional, capped by the server temperature: 0.2, // optional; ignored by models that reject it json: false, // true → ask for a JSON object and parse it into res.json }); // res = { content: "…", model: "smart", stop_reason: "end_turn", usage: { input_tokens: 12, output_tokens: 40 }, json?: any } ``` - `input` is a prompt string or an array of messages: `{ role: "user" | "assistant" | "system", content: string | ContentBlock[] }`. System messages are hoisted into the system prompt. - Content blocks: `{ type: "text", text }`, `{ type: "image", data: "", media_type: "image/png" }`, `{ type: "image", url: f.url }` (an upload of this site), `{ type: "image", url: "data:image/png;base64,…" }` or `{ type: "image", url: "https://…" }`. The OpenAI shape `{ type: "image_url", image_url: { url } }` is accepted too. - **Uploads of this site work directly:** pass the `url` that `backyard.files.upload` (or `backyard.ai.image`) returned. The server reads the file from storage — nothing is fetched — and detects its type from the bytes (png, jpeg, gif or webp). Another site's file, an unknown file or any other site-relative URL is rejected (`bad_request`). - Other URLs must be absolute and **publicly reachable** (the provider or Backyard's guarded fetcher downloads them; private and internal addresses are refused). - Images you only have in the browser (a picked `File` you did not upload, a canvas) go as a data URL. - Images are limited to 20 MiB each, and images loaded from URLs to 64 MiB per request. ```js const f = await backyard.files.upload(input.files[0]); // or a file uploaded earlier const { content } = await backyard.ai.chat([{ role: "user", content: [ { type: "text", text: "Describe this photo in one sentence." }, { type: "image", url: f.url }, ] }]); // Not uploaded: send it as a data URL. const toDataURL = (blob) => new Promise((ok, fail) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => fail(r.error); r.readAsDataURL(blob); }); canvas.toBlob(async (png) => { const { content } = await backyard.ai.chat([{ role: "user", content: [ { type: "text", text: "What did I draw?" }, { type: "image", url: await toDataURL(png) }, ] }]); }); ``` - `stop_reason` is normalized to `end_turn`, `max_tokens`, `stop_sequence`, `refusal` or `other`. - With `json: true`, `res.json` is the parsed object (`undefined` if the model did not return valid JSON — validate it). Describe the exact shape you want in the prompt. ```js const { json } = await backyard.ai.chat( `Classify this feedback. Reply with JSON {"sentiment": "positive"|"neutral"|"negative", "topics": string[]}.\n\n${text}`, { json: true, temperature: 0 }); ``` Keep conversations in memory (or in the db) and send the whole history each turn: ```js const history = []; async function ask(q) { history.push({ role: "user", content: q }); const { content } = await backyard.ai.chat(history, { system: "Be concise." }); history.push({ role: "assistant", content }); return content; } ``` ### stream `backyard.ai.stream(input, opts)` starts a streaming completion immediately (same options as `chat`, plus `onText(text)`). The returned object is an async iterable of `{ text }` chunks and has a `done` promise resolving to the final result (`{ content, model, stop_reason, usage }`), a `text` property with everything received so far and `abort()` (then `done` rejects with `aborted`; breaking out of a `for await` loop does not cancel the request). Every AI method also accepts an `AbortSignal` as `signal`. ```js const s = backyard.ai.stream(history, { system: "Be concise.", onText: (t) => (out.textContent += t) }); const final = await s.done; // rejects with BackyardError on failure // or iterate: let text = ""; for await (const chunk of backyard.ai.stream("Write a haiku about Mondays")) text += chunk.text; ``` ### image ```js const img = await backyard.ai.image("isometric illustration of a tiny office garden", { size: "1024x1024", n: 1, model: "image" }); // img = { id: "01JNV…", url: "/__backyard/files/01JNV…/image-1.png", content_type: "image/png", // model: "image", images: [{ id, url, content_type }] } // id/url/content_type = first image ``` Generated images are stored as site files: they appear in `files.list()`, and `id` works with `backyard.files.get(id)` / `backyard.files.delete(id)`. ### embed ```js const v = await backyard.ai.embed("vacation policy"); // number[] const vs = await backyard.ai.embed(["doc one", "doc two"]); // number[][] (same order) const cosine = (a, b) => { let d = 0, na = 0, nb = 0; for (let i = 0; i < a.length; i++) { d += a[i] * b[i]; na += a[i] ** 2; nb += b[i] ** 2; } return d / Math.sqrt(na * nb); }; ``` ### models ```js const { default: def, models } = await backyard.ai.models(); // models = [{ name: "smart", kind: "chat", provider: "anthropic", description: "Best quality" }, { name: "image", kind: "image", … }] const chatModels = models.filter((m) => m.kind === "chat"); ``` `kind` is `chat` (for `chat`/`stream`), `image` (for `image`) or `embedding` (for `embed`). Provider model ids (`model`) are only included when the admin enables it. ## Warehouse Read-only analytics queries against the company warehouse (BigQuery, Postgres/Redshift, Athena). Not available on the Cloudflare edition or when not configured (`unsupported`). ```js // Named query (pre-approved by admins, parameters use @name in the SQL) const r = await backyard.warehouse.named("weekly_active_users", { team: "growth" }); // Raw SQL (only if the server allows it; else "forbidden"). Positional ? placeholders. const r2 = await backyard.warehouse.query( "SELECT day, COUNT(*) AS orders FROM orders WHERE region = ? GROUP BY day ORDER BY day", ["emea"], { source: "default" }); // r = { columns: [{ name: "day", type: "DATE" }, { name: "orders", type: "INT64" }], // rows: [{ day: "2026-01-01", orders: 120 }, …], row_count: 7, truncated: false } const { sources, queries } = await backyard.warehouse.sources(); // sources = [{ name: "default", type: "bigquery", description }], queries = [{ name, description, params: ["team"], source }] ``` - Raw SQL must start with `SELECT` or `WITH` (after comments) unless the admin allows any statement; Postgres sources run in a read-only transaction. - Results are capped (default 10,000 rows, and 32 MiB of row data → `truncated: true`) and time out after 60 s by default. Aggregate in SQL instead of fetching wide raw rows. - Column `type` is the engine's type name. Values arrive as JSON: dates/timestamps as strings, big integers/decimals may be strings — convert with `Number()` when charting. - Rate limited per user; cache results in memory instead of re-querying on every render. ## Realtime rooms Rooms broadcast messages and track **presence** (who is connected, with a small state object each). The SDK keeps **one websocket per page**, created on first use (room, subscribe or watch), reconnects with exponential backoff (0.5 s → 10 s, jitter) and transparently re-joins rooms with the latest presence state. ```js const room = backyard.socket.room("lobby", { state: { status: "online" } }); // sync; joins automatically ``` | Member | Description | |---|---| | `room.on("message", (data, from) => …)` | A message from another member (or yourself with `echo`). `from = { cid, user: UserSummary }`. | | `room.on("join" \| "leave" \| "update", (member) => …)` | Someone joined, left or changed their state. | | `room.on("members", (members) => …)` | Full member list (excluding yourself) after any presence change. | | `room.members` | *sync* current `Member[]` (excluding yourself). | | `room.self` | *sync* your own `Member` once joined, else `null`. | | `room.state`, `room.joined` | *sync* the presence state you publish; whether the server confirmed the membership on the current connection. | | `room.send(data, { to?, echo? })` | *sync* broadcast any JSON (≤ 64 KiB); `to` = a member's `cid` for a direct message; `echo: true` also delivers it to yourself (default `false`). Queued while reconnecting. | | `room.setState(state)` | *sync* replace your presence state (JSON object ≤ 8 KiB). | | `room.updateState(partial)` | *sync* shallow-merge into your presence state. | | `room.on("error", (err) => …)` | The server rejected joining (e.g. too many rooms), a message (too large, unknown `to`) or a state update (over 8 KiB). Without a handler these go to `socket.on("error")`. | | `room.off(event, handler)` | Remove a handler (`on` also returns an unsubscribe function). | | `room.leave()` | Leave the room. | | `backyard.socket.connected` | *sync* whether the websocket is connected. | | `backyard.socket.cid`, `backyard.socket.user` | *sync* this tab's connection id and the user the server identified, once connected (else `null`). | | `backyard.socket.on("open" \| "close" \| "error", handler)` | Connection status changes (show an "offline" badge) and realtime errors such as `rate_limited`. | | `backyard.socket.connect()` / `.disconnect()` | Open the websocket without rooms or subscriptions / close it until it is used again. | ```ts interface Member { cid: string; user: UserSummary; state: object; joined_at: string } ``` - `cid` identifies a **connection** (one per browser tab); the same person in two tabs appears twice — group by `member.user.id` for "people here". - Room names: `^[A-Za-z0-9_\-:.]{1,128}$`. Rooms are per site and exist while someone is in them. - Messages are **not stored**: late joiners do not see earlier messages. Persist what matters in the db. - Calling `socket.room(name)` again with the same name returns the same room. - Keep presence small and throttle `updateState` (≤ 20/s); the server allows 60 messages/s (burst 120) per connection. ```js const room = backyard.socket.room("standup", { state: { speaking: false } }); room.on("members", (members) => renderPeople([room.self, ...members].filter(Boolean))); room.on("message", (data, from) => { if (data.type === "reaction") floatEmoji(data.emoji, from.user.name); }); reactBtn.onclick = () => room.send({ type: "reaction", emoji: "🎉" }, { echo: true }); backyard.socket.on("close", () => badge.hidden = false); backyard.socket.on("open", () => badge.hidden = true); ``` ## Errors HTTP errors have the body `{"error": {"code": "not_found", "message": "…", "login_url"?: "…"}}`. The SDK turns them into `BackyardError` with the same `code`. | `code` | HTTP | Meaning / what to do | |---|---|---| | `bad_request` | 400 (405) | Invalid input: collection/id/field name, query operator, JSON body, a model of the wrong kind; `405` when the method is not allowed for the path. Fix the call. | | `unauthenticated` | 401 | Not signed in or session/token expired. The SDK redirects to `loginUrl`. | | `forbidden` | 403 | Missing `X-Backyard` header or cross-origin write, user not allowed (domain/group restrictions), raw SQL disabled, site owned by someone else (deploys). | | `not_found` | 404 | Unknown document/file/user/named query/model alias. (`get` methods return `null` instead.) | | `conflict` | 409 | `create` with an id that exists. Use `set` or `update`. | | `too_large` | 413 | Document > 256 KiB, upload or deploy file over the limit, too many documents in the site. | | `rate_limited` | 429 | Slow down; `err.retryAfter` / `Retry-After` gives seconds to wait. | | `unsupported` | 501 | Feature not configured on this server (AI, warehouse, realtime, tokens). | | `upstream_error` | 502/504 | The AI provider or warehouse failed or timed out. Retry later. | | `internal` | 500 | Server bug; retry, report if persistent. | | `network_error` | 0 | SDK only: offline, DNS or connection failure. | | `aborted` | 0 | SDK only: request cancelled. | | `bad_response` | 0 | SDK only: a non-JSON answer (often a proxy login page — reload the page). | ## Limits Defaults of the Go server; administrators can change them (ask `GET /__backyard/api/info` on the hub for the deploy limits). The Cloudflare free-tier edition has smaller limits and daily caps — see [deploy.md](https://test.maniflex.dev/docs/deploy.md#cloudflare-edition-free-tier). | Area | Limit | |---|---| | Document | 256 KiB JSON; 100,000 documents per site | | Query | `limit` default 100, max 1000; `in`/`not-in` ≤ 30 values; field depth ≤ 8 | | Upload | 100 MiB per file (`files.upload`) | | Site (deploy) | 500 MiB total, 10,000 files, 100 MiB per file | | Websocket | 64 KiB per frame; 60 msgs/s sustained, burst 120; 50 rooms and 100 subscriptions per connection; presence state ≤ 8 KiB; 50 connections per user | | Rate limits (per user) | API 600/min · db writes 300/min · AI 20 calls/min and 2,000,000 tokens/day · uploads 60/min · warehouse 30 queries/min · deploys 60/h | | AI | `max_tokens` capped by the server (default 8192); chat images ≤ 20 MiB each, ≤ 64 MiB per request for images loaded from URLs (uploads included) | | Warehouse | 10,000 rows and 32 MiB per result, 60 s timeout | ## HTTP API The SDK is a thin client over this API; use it directly from scripts, other languages or `fetch`. Base: `https://.test.maniflex.dev/__backyard/api` (host mode) or `https://test.maniflex.dev/s//__backyard/api` (path mode). **Rules** - JSON in and out (`Content-Type: application/json`). - **CSRF:** every non-GET/HEAD request must send `X-Backyard: 1`; if an `Origin` header is present it must be the site's own origin (host mode) or the hub origin (path mode). Otherwise `403 forbidden`. - **Auth:** the browser session (cookie, or the identity-aware proxy) — or `Authorization: Bearer ` with a personal API token (`backyard token create`) or a static CI token. - Errors: status + `{"error": {"code", "message", "login_url"?}}`; `429` carries `Retry-After`. ```sh TOKEN=$(backyard token create --name script --ttl 24h) # prints a personal API token curl -s -H "Authorization: Bearer $TOKEN" https://lunch-vote.test.maniflex.dev/__backyard/api/db/spots curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "X-Backyard: 1" -H "Content-Type: application/json" \ -d '{"name":"Tacos"}' https://lunch-vote.test.maniflex.dev/__backyard/api/db/spots ``` ### Identity | Method & path | Result | |---|---| | `GET /me` | `User` | | `GET /users/{email}` | `User` \| 404 | | `GET /site` | `SiteMeta` of the current site | `SiteMeta = {name, url, title, description, created_at, created_by, updated_at, updated_by, deploy_id, file_count, bytes, owners: []}` — `title`/`description` come from the root `index.html`'s `` and `<meta name="description">`. ### Database | Method & path | Body | Result | |---|---|---| | `GET /db` | | `{"collections": ["posts", …]}` | | `POST /db/{c}` | data (optional `id`) | `201` doc; `409 conflict` if `id` exists | | `GET /db/{c}?limit=&offset=&order=field:asc,field2:desc` | | `{"docs": […], "has_more": bool}` | | `POST /db/{c}/query` | query object | `{"docs": […], "has_more": bool}` | | `GET /db/{c}/{id}` | | doc \| `404` | | `PUT /db/{c}/{id}` | data | doc (create or replace; version + 1) | | `PATCH /db/{c}/{id}` | partial data | doc (shallow merge; `null` deletes a key); `404` if missing | | `DELETE /db/{c}/{id}` | | `204` (also when missing) | Every successful write publishes a realtime event to subscribers of the collection. ### Files | Method & path | Body | Result | |---|---|---| | `POST /files` | `multipart/form-data`: field `file` (+ optional `name`) | `201` FileInfo | | `GET /files?limit=&offset=` | | `{"files": [FileInfo], "has_more": bool}` newest first | | `GET /files/{id}` | | FileInfo \| `404` | | `DELETE /files/{id}` | | `204` (also when missing) | Bytes are served (not under `/api`) at `/__backyard/files/{id}/{name}` with Range support (every type except passive ones — raster images, audio/video, plain text, CSV, PDF, JSON, octet-stream — with `Content-Security-Policy: sandbox`). ### AI `POST /ai/chat` ```jsonc { "messages": [ {"role": "user", "content": "Summarize my tasks"} ], // content may also be [{"type":"text","text":"…"}, {"type":"image","data":"<base64>","media_type":"image/png"}, {"type":"image","url":"https://…"}] "model": "smart", // alias; default = server default "system": "You are…", // optional; role:"system" messages are hoisted "max_tokens": 4096, // optional, capped by the server "temperature": 0.2, // optional "json": false, // optional: ask for a JSON object "stream": false } ``` - Non-streaming → `{"content": "…", "model": "smart", "stop_reason": "end_turn", "usage": {"input_tokens": 12, "output_tokens": 40}}` - `"stream": true` → `text/event-stream`, one `data: <json>` line per event: `{"type":"text","text":"…"}` (repeated), then `{"type":"done","model":"smart","stop_reason":"end_turn","usage":{…}}`, or `{"type":"error","error":{"code":"upstream_error","message":"…"}}`. Lines starting with `:` (`: ping` keep-alives while the model is silent) are comments — ignore them. | Method & path | Body | Result | |---|---|---| | `POST /ai/image` | `{"prompt", "model"?, "size"?: "1024x1024", "n"?: 1}` | `{"model": "image", "images": [{"id", "url": "/__backyard/files/…", "content_type": "image/png"}]}` | | `POST /ai/embed` | `{"input": string \| string[], "model"?}` | `{"model": "embed", "embeddings": [[…], …], "usage": {"input_tokens", "output_tokens"}}` (one vector per input string) | | `GET /ai/models` | | `{"default": "smart", "models": [{"name", "kind", "provider", "model"?, "description"?}]}` | Clients pass aliases; raw `"provider/model-id"` names work only when the admin enables `ai.allow_raw_models`. ### Warehouse | Method & path | Body | Result | |---|---|---| | `POST /warehouse/query` | `{"sql": "SELECT … WHERE team = ?", "params": ["growth"], "source": "default"}` or `{"query": "weekly_active_users", "params": {"team": "growth"}}` | `{"columns": [{"name","type"}], "rows": [{…}], "row_count": 7, "truncated": false}` | | `GET /warehouse` | | `{"sources": [{name, type, description}], "queries": [{name, description, params, source}]}` | ## Websocket protocol `GET /__backyard/api/ws` (same auth as the API). JSON text frames, max 64 KiB. The first frame from the server is `{"t":"hello","cid":"c_8f3a…","user":{User}}`. Client → server (`id` is an optional correlation id echoed in `ack`/`joined`/`error`; `leave`, `state` and `send` frames with an `id` are also answered with `{"t":"ack","id"}` on success): | Frame | Meaning | |---|---| | `{"t":"sub","id":"s1","collection":"posts"}` | Subscribe to db changes → `{"t":"ack","id":"s1"}` | | `{"t":"unsub","id":"s1"}` | Unsubscribe → ack | | `{"t":"join","id":"r1","room":"lobby","state":{…}}` | Join with presence state → `{"t":"joined","id":"r1","room":"lobby","self":Member,"members":[Member…]}` (members excludes self) | | `{"t":"leave","room":"lobby"}` | Leave a room | | `{"t":"state","room":"lobby","state":{…}}` | Replace own presence state | | `{"t":"send","room":"lobby","data":any,"to":"c_…","echo":false}` | Broadcast (or direct message with `to`) | | `{"t":"ping"}` | → `{"t":"pong"}` | Server → client: | Frame | Meaning | |---|---| | `{"t":"db","sub":"s1","collection":"posts","op":"create"\|"update"\|"delete","doc":{…}\|null,"doc_id":"…"}` | Database change (`doc` is the flattened document; `null` for deletes, and for a create or update too large to broadcast: read it with `GET /db/{c}/{doc_id}`) | | `{"t":"presence","room":"lobby","op":"join"\|"leave"\|"update","member":Member}` | Presence change | | `{"t":"msg","room":"lobby","from":{"cid":"…","user":UserSummary},"data":any}` | Room message | | `{"t":"error","id":…,"error":{"code","message"}}` | Error (e.g. `rate_limited`, `bad_request`) | `Member = {"cid", "user": UserSummary, "state": {…}, "joined_at": RFC3339}` (`joined_at` with exactly three fractional digits; `members` sorted by `joined_at`, then `cid`), `UserSummary = {id, name, email, avatar_url}`. Errors never close the connection. The server pings every 25 s and closes connections that do not answer within 60 s. Limits: 50 rooms and 100 subscriptions per connection, 60 msgs/s (burst 120), presence state ≤ 8 KiB, 50 connections per user (more are refused with HTTP `429`). The `Origin` must be the site's own origin (host mode) or the hub's (path mode). ## Hub endpoints On the hub host (`https://test.maniflex.dev`): | Path | Purpose | |---|---| | `GET /__backyard/api/me` | Current user | | `GET /__backyard/api/sites` | `{"sites": [SiteMeta]}` — the site directory | | `POST /__backyard/api/tokens` | `{"name"?, "ttl_hours"?}` → `{"token", "expires_at", "name"?}` (personal API token; `ttl_hours` at most the server's token TTL, default 720; needs `X-Backyard: 1`; not for guests or static tokens) | | `GET /__backyard/api/info` | Server info: URL mode, auth mode, deploy methods, limits | | `/__backyard/api/deploy/…` | Deploy gateway (see [deploy.md](https://test.maniflex.dev/docs/deploy.md)) | | `/docs`, `/llms.txt`, `/llms-full.txt` | Documentation for people and agents | | `/__backyard/healthz`, `/__backyard/readyz` | Health checks (no auth) | Sites embed in each other freely (`<iframe src="https://other.test.maniflex.dev/">`); there is no `X-Frame-Options`. --- <!-- Source: https://test.maniflex.dev/docs/recipes.md --> # Backyard recipes Complete, copy-paste apps. Each recipe is a single `index.html`: save it in an empty folder, run `backyard dev` to try it locally and `backyard deploy --site <name>` to publish. They use only the documented SDK (see [api.md](https://test.maniflex.dev/docs/api.md)) and no external libraries. Conventions used throughout: - The SDK is loaded with `<script src="/__backyard/backyard.js"></script>`; app code runs in a `<script type="module">` after it, so it can use top-level `await`. - User content is inserted with `textContent` (or escaped with `esc()`), never raw `innerHTML`. - `key(s)` turns an email into a valid document id: ids allow only `A-Z a-z 0-9 _ - : .`. - Lists are rendered from `watch(...)`, so every open tab updates live. | # | Recipe | APIs | |---|---|---| | 1 | [Lunch-spot voting](#1-lunch-spot-voting) | db, watch, rooms | | 2 | [Live all-hands poll](#2-live-all-hands-poll) | db, watch, rooms | | 3 | [Multiplayer cursors](#3-multiplayer-cursors) | rooms, presence, messages | | 4 | [Chat room with presence](#4-chat-room-with-presence) | db, watch, rooms | | 5 | [AI summarizer (streaming)](#5-ai-summarizer-streaming) | ai.stream | | 6 | [Image generator gallery](#6-image-generator-gallery) | ai.image, db | | 7 | [File drop and gallery](#7-file-drop-and-gallery) | files, db | | 8 | [Kanban board](#8-kanban-board) | db, watch | | 9 | [Warehouse KPI dashboard](#9-warehouse-kpi-dashboard) | warehouse, SVG | | 10 | [Guestbook](#10-guestbook) | db, watch, identity | | 11 | [Game leaderboard](#11-game-leaderboard) | db, watch, rooms | | 12 | [Sharing code between sites](#12-sharing-code-between-sites) | absolute URLs | --- ## 1. Lunch-spot voting Everyone adds places and votes once per day; results re-sort live and show how many people are deciding right now. Data: `spots` (`{name}`, id derived from the name so duplicates are rejected) and `votes` (one doc per person per day: id `<day>.<user>`). ```html <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Lunch Vote

Where do we eat today?

Loading…

    ``` --- ## 2. Live all-hands poll The host opens `…/?host`, types a question and options, and starts a round; the audience opens the plain URL on their phones and votes. Results update live for everyone. Data: `poll` (a single doc `current`: `{question, options, round, open}`) and `answers` (one doc per person per round). ```html Live Poll
    Loading…
    ``` --- ## 3. Multiplayer cursors Everyone's pointer is shared through room presence (positions are fractions of the window, so different screen sizes line up); clicks broadcast a "ping" ripple as a room message. Updates are throttled to 20/s with a trailing update so the last position is never lost. ```html Cursors

    Move your pointer — everyone sees it. Click to ping.

    ``` --- ## 4. Chat room with presence Messages are stored in the db (so late joiners see history); presence shows who is online and who is typing. The typing flag is only sent when it changes. ```html Team Chat
    #general
    ``` --- ## 5. AI summarizer (streaming) Paste text, pick a style, and watch the summary stream in. Handles the "AI not configured" and rate-limit cases. ```html Summarizer

    Summarizer

    ``` --- ## 6. Image generator gallery Generate images from prompts; every result is saved (the image as a site file, its prompt in the db) and appears in a shared gallery for everyone. ```html Image Studio

    Image Studio

    ``` --- ## 7. File drop and gallery Drag files onto the page (or pick/paste them). Each upload is mirrored as a db document so the gallery updates live for everyone; deleting removes both the file and the document. ```html File Drop

    File Drop

    ``` --- ## 8. Kanban board Three columns, drag and drop to move cards, live for everyone. A card is `{title, status, order}`; moving a card sets its status and a new `order` (a timestamp, so it lands at the bottom). ```html Team Board

    Team Board

    ``` --- ## 9. Warehouse KPI dashboard Stat tiles plus an SVG line chart (with hover readout) from a **named query**. When the warehouse is not available (`unsupported`, `forbidden`, `not_found`…) it falls back to clearly labeled demo data. An admin defines the query in `backyard.yaml`: ```yaml warehouse: sources: default: { type: postgres, dsn: "${ANALYTICS_DSN}" } queries: daily_signups: description: New signups per day params: [days] sql: SELECT day, signups FROM analytics.daily_signups WHERE day >= CURRENT_DATE - CAST(@days AS integer) ORDER BY day ``` ```html Signups KPI

    Daily signups

    Last 30 days
    –
    Daily average
    –
    Last 7 vs prior 7 days
    –
    ``` --- ## 10. Guestbook The smallest useful realtime app: sign with one click (your name comes from the company login), newest first, delete your own entries. ```html Guestbook

    Guestbook

      ``` --- ## 11. Game leaderboard A five-second click-frenzy game with a shared top-10. Each player has one document (their best score), so the leaderboard is a simple sorted query; a room shows who is playing now. ```html Click Frenzy

      Click Frenzy

      Click as fast as you can for 5 seconds.

      ``` --- ## 12. Sharing code between sites Sites are plain files on internal URLs, so one site can be a shared library for others. Deploy a folder `ui-kit/` containing `kit.js` and `kit.css`, then include it from any site by **absolute** URL: ```html ``` ```js // ui-kit/kit.js — a tiny shared helper used by many sites window.kit = { // Renders the signed-in user's avatar chip into el. backyard.* calls act on the // INCLUDING site (the page's site), not on ui-kit. async userChip(el) { const me = await backyard.me(); el.textContent = me.name; el.title = me.email ?? ""; }, key: (s) => s.replace(/[^A-Za-z0-9_\-:.]/g, "_").slice(0, 128), }; ``` - Load the Backyard SDK in the including page before the shared script. - Data APIs (`db`, `files`, `socket`…) always use the site the **page** belongs to. To read another site's data, embed that site in an `