diff --git a/AGENTS.md b/AGENTS.md
index 94f9c5a..2ec9777 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,15 +11,29 @@ Assume every commit and every line of history will be public.
an encyclopedia of alternatives.
- Do not add navigation to a section until that section has a real page or
generated source.
-- The sidebar in `blume.config.ts` is the public route manifest.
- `bun run verify:routes` checks every sidebar route against the built output.
+- The routes written as string literals in the `blume.config.ts` sidebar are
+ the public route manifest. `bun run verify:routes` checks every one against
+ the built output, and `scripts/build-llms-index.mjs` keeps exactly those
+ entries in `llms.txt`. Both read the config source between `sidebar:` and
+ `openapi:`, so the generated sections — API operations, CLI commands — are
+ computed above the config object and spread into the sidebar. Writing one of
+ those routes as a literal inside the sidebar would claim a manifest entry it
+ cannot keep, and break both.
+- Header tabs are the top-level sections: Platform (`/`), Zero, CLI, API, and
+ Platform API. A tab's `path` is a URL prefix, and the sidebar group rooted at
+ that same path is the section the tab scopes the sidebar to — a new tab means
+ both, or it renders an empty sidebar. Frozen generated prefixes are what make
+ tabs work here: they are already stable section roots, so no route moves.
+- Reference navigation is derived, never hand-listed. The API sections read the
+ OpenAPI snapshot through Blume's own extractor, and the CLI section reads the
+ command headings out of the generated CLI page, so an endpoint or command
+ that ships appears in the sidebar without an edit here.
- Route policy: one page per task, not one page per toggle; a page lives in one
primary place and is cross-linked elsewhere. Authored URLs may move freely
before launch, without compatibility redirects. Generated routes are frozen
public contracts and can never move: `/api/reference`,
`/platforms/api/reference`, `/cli`, `/errors` (RFC 9457 error type URIs point
- at `/errors/`), and `/changelog`. Because those prefixes can never be
- unified, the site uses a single sidebar with no tabs.
+ at `/errors/`), and `/changelog`.
- Test behavior and built output. Do not read implementation source and assert
that a string exists as a substitute for exercising the result.
diff --git a/blume.config.ts b/blume.config.ts
index 759ce6e..8af63f9 100644
--- a/blume.config.ts
+++ b/blume.config.ts
@@ -1,4 +1,8 @@
+import { readFileSync } from "node:fs";
+
import { defineConfig } from "blume";
+import { componentSlug } from "blume/components/slug.ts";
+import { extractOperations } from "blume/openapi/model.ts";
const deploymentBase = "/docs";
const haskoy = {
@@ -12,6 +16,89 @@ const haskoy = {
],
};
+/**
+ * Sidebar groups for one OpenAPI spec: a collapsible group per tag, holding
+ * that tag's operation pages in spec order.
+ *
+ * Blume generates those pages and their routes from the spec, so the sidebar
+ * reads the same spec through Blume's own extractor rather than a hand-kept
+ * list that drifts every time an endpoint lands.
+ */
+const referenceGroups = (spec: string, route: string) => {
+ const { operations, tags } = extractOperations(
+ JSON.parse(readFileSync(spec, "utf8")),
+ route
+ );
+ return tags.map((tag) => ({
+ collapsed: true,
+ display: "group" as const,
+ items: operations
+ .filter((operation) => operation.tagSlug === tag.slug)
+ .map((operation) => operation.route),
+ label: tag.name,
+ }));
+};
+
+/**
+ * Sidebar entries for the CLI: one group per top-level command, holding that
+ * command's subcommands.
+ *
+ * The CLI reference is a single generated page with a heading per command, so
+ * the entries link to heading anchors and are read back out of that page —
+ * a command that ships is in the sidebar without anyone editing this file.
+ * Anchors are slugged the way Blume slugs headings, so they always agree.
+ */
+const cliCommands = () => {
+ let page: string;
+ try {
+ page = readFileSync("./content/cli/index.md", "utf8");
+ } catch {
+ // The page is an ignored build overlay, so a bare `blume` run without the
+ // prepare step would otherwise fail here as a bare ENOENT.
+ throw new Error(
+ "content/cli/index.md is missing. Run scripts/prepare-generated.mjs before Blume; the CLI sidebar is built from that page."
+ );
+ }
+ const commands = [...page.matchAll(/^## `sf ([^`]+)`$/gmu)].map((heading) => ({
+ href: `/cli#${componentSlug(`sf ${heading[1]}`)}`,
+ // Argument placeholders (`BUILD`, `[PATH]`) name the call, not the
+ // command, so they are dropped from the label and the grouping.
+ words: heading[1].split(/\s+/u).filter((word) => !/^[A-Z[]/u.test(word)),
+ }));
+
+ const groups = new Map();
+ for (const command of commands) {
+ const name = command.words[0];
+ groups.set(name, [...(groups.get(name) ?? []), command]);
+ }
+
+ return [...groups].map(([name, entries]) => {
+ const [first] = entries;
+ if (entries.length === 1) {
+ return { href: first.href, label: `sf ${first.words.join(" ")}` };
+ }
+ return {
+ collapsed: true,
+ display: "group" as const,
+ items: entries.map((entry) => ({
+ href: entry.href,
+ label:
+ entry.words.length === 1 ? "Overview" : entry.words.slice(1).join(" "),
+ })),
+ label: `sf ${name}`,
+ };
+ });
+};
+
+const restReference = referenceGroups(
+ "./generated/openapi/api.json",
+ "/api/reference"
+);
+const platformReference = referenceGroups(
+ "./generated/openapi/platform.json",
+ "/platforms/api/reference"
+);
+
export default defineConfig({
title: "Spacefast Docs",
description: "Publish sites and build apps with Spacefast through the CLI, API, SDK, and MCP.",
@@ -69,6 +156,17 @@ export default defineConfig({
{ label: "Agent setup", href: "/agents", icon: "bot" },
],
repo: true,
+ // Header sections. A tab's `path` is a URL prefix, and the sidebar group
+ // rooted at that prefix is the section it scopes to — so reading the CLI
+ // reference shows CLI navigation, not the whole site. The `/` tab spans
+ // everything the others don't claim.
+ tabs: [
+ { label: "Platform", path: "/" },
+ { label: "Zero", path: "/zero-runtime" },
+ { label: "CLI", path: "/cli" },
+ { label: "API", path: "/api" },
+ { label: "Platform API", path: "/platforms" },
+ ],
sidebar: {
display: "flat",
items: [
@@ -114,9 +212,8 @@ export default defineConfig({
},
{
label: "Primitives",
- root: "/zero-runtime",
+ root: "/functions",
items: [
- "/zero-runtime",
"/functions",
"/functions/php",
"/database",
@@ -172,11 +269,6 @@ export default defineConfig({
"/guides/local-development",
],
},
- {
- label: "Platforms",
- root: "/platforms",
- items: ["/platforms"],
- },
{
label: "Account & teams",
root: "/account",
@@ -192,17 +284,55 @@ export default defineConfig({
root: "/reference",
items: [
"/reference",
- "/api",
- "/api/versioning",
- { label: "REST API reference", href: "/api/reference" },
- { label: "Platform API reference", href: "/platforms/api/reference" },
- "/cli",
"/reference/sdk",
"/errors",
{ label: "Changelog", href: "/changelog" },
"/changelog/packages",
],
},
+ // Header-tab sections. Each is rooted at the matching tab's path, which
+ // is what scopes the sidebar to it.
+ {
+ label: "Zero",
+ root: "/zero-runtime",
+ items: [
+ "/zero-runtime",
+ "/zero-runtime/build",
+ "/zero-runtime/client",
+ "/zero-runtime/server",
+ "/zero-runtime/endpoints",
+ "/zero-runtime/authentication",
+ "/zero-runtime/styling",
+ "/zero-runtime/components",
+ "/zero-runtime/commands",
+ "/zero-runtime/managed-content",
+ "/zero-runtime/move-from-lakebed",
+ ],
+ },
+ {
+ label: "CLI",
+ root: "/cli",
+ items: ["/cli", ...cliCommands()],
+ },
+ {
+ label: "API",
+ root: "/api",
+ items: [
+ "/api",
+ "/api/versioning",
+ { label: "All endpoints", href: "/api/reference" },
+ ...restReference,
+ ],
+ },
+ {
+ label: "Platform API",
+ root: "/platforms",
+ items: [
+ "/platforms",
+ { label: "All endpoints", href: "/platforms/api/reference" },
+ ...platformReference,
+ ],
+ },
],
},
},
diff --git a/bun.lock b/bun.lock
index fbb0e6c..c37baab 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,7 +5,7 @@
"": {
"name": "docs",
"dependencies": {
- "blume": "1.5.1",
+ "blume": "1.5.3",
},
"devDependencies": {
"@spacefast/sdk": "^0.0.24",
@@ -718,7 +718,7 @@
"bindings": ["bindings@1.5.0", "https://npmproxy.autoproxxy.com:2443/ask-in-opers-wbudsfo9i2e98e9wdu7s98grt9ui32rew/bindings/-/bindings-1.5.0.tgz", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
- "blume": ["blume@1.5.1", "", { "dependencies": { "@astrojs/check": "^0.9.0", "@astrojs/markdown-satteri": "^0.3.2", "@astrojs/mdx": "^7.0.0", "@astrojs/node": "^11.0.0", "@astrojs/react": "^6.0.0", "@astrojs/vercel": "^11.0.3", "@asyncapi/converter": "^2.0.2", "@clack/prompts": "^1.7.0", "@iconify-json/lucide": "^1.2.115", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.3", "@modelcontextprotocol/sdk": "^1.29.0", "@orama/orama": "^3.1.18", "@pierre/diffs": "^1.2.11", "@scalar/astro": "^0.4.5", "@scalar/openapi-parser": "^0.28.8", "@scalar/openapi-types": "^0.9.1", "@shikijs/transformers": "^4.2.0", "@shikijs/twoslash": "^4.2.0", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", "@types/mdast": "^4.0.4", "@vercel/analytics": "^2.0.1", "ai": "^7.0.42", "astro": "^7.1.0", "babel-plugin-react-compiler": "^1.0.0", "chokidar": "^5.0.0", "citty": "^0.1.6", "consola": "^3.4.0", "cross-spawn": "^7.0.6", "dompurify": "^3.4.13", "dotenv": "^17.4.2", "epub-gen-memory": "^1.1.2", "fast-xml-parser": "^5.10.1", "get-tsconfig": "^4.14.1", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "html-escaper": "^3.0.3", "image-size": "^2.0.2", "jiti": "^2.4.0", "js-yaml": "^4.3.1", "katex": "^0.18.1", "markdown-table": "^3.0.4", "marked": "^18.0.5", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "mdast-util-to-string": "^4.0.0", "medium-zoom": "^1.1.0", "mermaid": "^11.16.1", "micromark-extension-gfm": "^3.0.0", "nanotar": "^0.3.0", "node-html-parser": "^9.0.0", "openapi-sampler": "^1.7.4", "p-limit": "^7.3.1", "p-map": "^7.0.6", "p-retry": "^8.0.0", "package-manager-detector": "^1.8.0", "pagefind": "^1.3.0", "pathe": "^2.0.0", "perfect-debounce": "^2.1.0", "picomatch": "^4.0.5", "react": "^19.0.0", "react-dom": "^19.0.0", "robots-parser": "^3.0.1", "satteri": "^0.9.5", "semver": "^7.8.5", "sharp": "^0.35.3", "shiki": "^4.2.0", "simple-icons": "^13.0.0", "string-width": "^8.1.0", "tailwindcss": "^4.3.3", "takumi-js": "^2.2.1", "tinyglobby": "^0.2.10", "twoslash": "^0.3.9", "typescript": "^6.0.3", "ufo": "^1.6.4", "undici": "^8.9.0", "write-file-atomic": "^8.0.0", "zod": "^4.3.6" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^3.0.0", "@astrojs/cloudflare": "^14.0.0", "@astrojs/netlify": "^8.0.0", "@astrojs/svelte": "^9.0.0", "@astrojs/vue": "^7.0.0", "@mixedbread/sdk": "^0.76.0", "@notionhq/client": "^2.2.15", "@openrouter/ai-sdk-provider": "^3.0.0", "@oramacloud/client": "^2.1.0", "@sanity/client": "^6.21.0 || ^7.0.0", "algoliasearch": "^5.55.0", "flexsearch": "^0.8.0", "typesense": "^3.0.0" }, "optionalPeers": ["@ai-sdk/openai-compatible", "@astrojs/cloudflare", "@astrojs/netlify", "@astrojs/svelte", "@astrojs/vue", "@mixedbread/sdk", "@notionhq/client", "@openrouter/ai-sdk-provider", "@oramacloud/client", "@sanity/client", "algoliasearch", "flexsearch", "typesense"], "bin": { "blume": "bin/blume.mjs" } }, "sha512-RnzoY+OHvchUJkXjF7s45SUW5rhuL81URWATEZoEl3psWeajsUu6JRyqDuy8bpYZjoth7P6gpS4IijZspv8Yrw=="],
+ "blume": ["blume@1.5.3", "", { "dependencies": { "@astrojs/check": "^0.9.0", "@astrojs/markdown-satteri": "^0.3.2", "@astrojs/mdx": "^7.0.0", "@astrojs/node": "^11.0.0", "@astrojs/react": "^6.0.0", "@astrojs/vercel": "^11.0.3", "@asyncapi/converter": "^2.0.2", "@clack/prompts": "^1.7.0", "@iconify-json/lucide": "^1.2.115", "@iconify/types": "^2.0.0", "@iconify/utils": "^3.1.3", "@modelcontextprotocol/sdk": "^1.29.0", "@orama/orama": "^3.1.18", "@pierre/diffs": "^1.2.11", "@scalar/astro": "^0.4.5", "@scalar/openapi-parser": "^0.28.8", "@scalar/openapi-types": "^0.9.1", "@shikijs/transformers": "^4.2.0", "@shikijs/twoslash": "^4.2.0", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", "@types/mdast": "^4.0.4", "@vercel/analytics": "^2.0.1", "ai": "^7.0.42", "astro": "^7.1.0", "babel-plugin-react-compiler": "^1.0.0", "chokidar": "^5.0.0", "citty": "^0.1.6", "consola": "^3.4.0", "cross-spawn": "^7.0.6", "dompurify": "^3.4.13", "dotenv": "^17.4.2", "epub-gen-memory": "^1.1.2", "fast-xml-parser": "^5.10.1", "get-tsconfig": "^4.14.1", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "html-escaper": "^3.0.3", "image-size": "^2.0.2", "jiti": "^2.4.0", "js-yaml": "^4.3.1", "katex": "^0.18.1", "markdown-table": "^3.0.4", "marked": "^18.0.5", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "mdast-util-to-string": "^4.0.0", "medium-zoom": "^1.1.0", "mermaid": "^11.16.1", "micromark-extension-gfm": "^3.0.0", "nanotar": "^0.3.0", "node-html-parser": "^9.0.0", "openapi-sampler": "^1.7.4", "p-limit": "^7.3.1", "p-map": "^7.0.6", "p-retry": "^8.0.0", "package-manager-detector": "^1.8.0", "pagefind": "^1.3.0", "pathe": "^2.0.0", "perfect-debounce": "^2.1.0", "picomatch": "^4.0.5", "react": "^19.0.0", "react-dom": "^19.0.0", "robots-parser": "^3.0.1", "satteri": "^0.9.5", "semver": "^7.8.5", "sharp": "^0.35.3", "shiki": "^4.2.0", "simple-icons": "^13.0.0", "string-width": "^8.1.0", "tailwindcss": "^4.3.3", "takumi-js": "^2.2.1", "tinyglobby": "^0.2.10", "twoslash": "^0.3.9", "typescript": "^6.0.3", "ufo": "^1.6.4", "undici": "^8.9.0", "write-file-atomic": "^8.0.0", "zod": "^4.3.6" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^3.0.0", "@astrojs/cloudflare": "^14.0.0", "@astrojs/netlify": "^8.0.0", "@astrojs/svelte": "^9.0.0", "@astrojs/vue": "^7.0.0", "@mixedbread/sdk": "^0.76.0", "@notionhq/client": "^2.2.15", "@openrouter/ai-sdk-provider": "^3.0.0", "@oramacloud/client": "^2.1.0", "@sanity/client": "^6.21.0 || ^7.0.0", "algoliasearch": "^5.55.0", "flexsearch": "^0.8.0", "typesense": "^3.0.0" }, "optionalPeers": ["@ai-sdk/openai-compatible", "@astrojs/cloudflare", "@astrojs/netlify", "@astrojs/svelte", "@astrojs/vue", "@mixedbread/sdk", "@notionhq/client", "@openrouter/ai-sdk-provider", "@oramacloud/client", "@sanity/client", "algoliasearch", "flexsearch", "typesense"], "bin": { "blume": "bin/blume.mjs" } }, "sha512-8oaUwDtMcEWdCxnUmnPnw6SJol93EAavUq7ihFcr6ur2mCPcdkMjMzphM5Y3qIAexwy2J/bsQKRzAWKqHqIUSw=="],
"body-parser": ["body-parser@2.3.0", "https://npmproxy.autoproxxy.com:2443/ask-in-opers-wbudsfo9i2e98e9wdu7s98grt9ui32rew/body-parser/-/body-parser-2.3.0.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
diff --git a/content/database/index.mdx b/content/database/index.mdx
index 5fe6772..2e91ff8 100644
--- a/content/database/index.mdx
+++ b/content/database/index.mdx
@@ -36,7 +36,7 @@ In a Functions worker, opt in through `sf.jsonc`:
The worker then receives `env.DB`. Use `prepare().bind()` with `.all()`,
`.first()`, `.run()`, or `.raw()`. `env.DB.exec()` runs DDL (data
definition language) statements, and `env.DB.batch()` groups statements.
-For the typed query contract, see [Zero's database](/zero-runtime#database).
+For a complete typed example, [build a Zero to-do app](/zero-runtime/build).
## Migrations
diff --git a/content/functions/index.mdx b/content/functions/index.mdx
index e3bdba5..a13876f 100644
--- a/content/functions/index.mdx
+++ b/content/functions/index.mdx
@@ -78,10 +78,10 @@ request. Next and OpenNext builds get `nodejs_compat` automatically.
Outbound network access is fail-closed. Spacefast denies it unless the
published version holds the `fetch` capability, so a `fetch()` call in your
-code does not silently add network authority. No Functions publish
-grants that capability. Outbound `fetch` is available only to
-[Zero](/zero-runtime) actions. To check which capabilities a published version
-holds, run `sf runtime status`.
+code does not add network authority. No Functions publish grants that
+capability. Zero server handlers cannot call `fetch` either. Use a Zero
+[platform service](/services) when one matches the job. To inspect a published
+Functions version, run `sf runtime status`.
## Variables
@@ -99,7 +99,7 @@ instead.
## Storage
Every worker receives `env.STORAGE`, the same space
-[object store](/storage) that [Zero](/zero-runtime#storage) uses. The binding has
+[object store](/storage) that Zero uses. The binding has
three methods:
- `upload(file)` takes a `Blob` and returns an object with `id`,
diff --git a/content/functions/php.md b/content/functions/php.md
index d194b9c..a2e0c47 100644
--- a/content/functions/php.md
+++ b/content/functions/php.md
@@ -36,7 +36,7 @@ suffix removed. A trailing `index` segment also drops:
Routes are literal. A segment that is empty, contains `[` or `]`, or starts
with `:` or `.` is not routed in this lane, so a pattern-named file such
as `functions/[id].php` stays an inert upload. A file you commit at the
-same key outranks the PHP route, and redirects and [Zero](/zero-runtime) actions
+same key outranks the PHP route, and redirects and [Zero](/zero-runtime) endpoints
win over it. This is the same precedence the
[JavaScript Functions](/functions) lane gets.
diff --git a/content/guides/local-development.mdx b/content/guides/local-development.mdx
index ef9e806..734d319 100644
--- a/content/guides/local-development.mdx
+++ b/content/guides/local-development.mdx
@@ -113,9 +113,7 @@ sf dev --state-backend sqlite
```
The local store is a development engine, not the database a published space gets. It is enough
-to build against, and two differences are worth holding on to. A failed `ctx.transaction()`
-does not roll back locally, so writes made inside it survive an error that would undo them in
-production. And the local engine has its own ceilings on rows, values, and stored objects,
+to build against. The local engine has its own ceilings on rows, values, and stored objects,
which are development guard rails rather than product limits. For those, see
[Plans](/account/plans).
diff --git a/content/index.mdx b/content/index.mdx
index e577bc3..f5f5368 100644
--- a/content/index.mdx
+++ b/content/index.mdx
@@ -38,8 +38,11 @@ Claim the space afterward to keep it.
Spaces are private by default. Grant access to people, links,
passwords, or the public, and collect comments on previews.
-
- Zero, Functions, and the space's database, storage, and email.
+
+ Build a full app on a space: server code, endpoints, auth, and styling.
+
+
+ Functions and the space's own database, storage, and email.
Attach your own hostname. DNS checks and SSL are automatic.
diff --git a/content/services/index.mdx b/content/services/index.mdx
index 1cec47f..6bb6320 100644
--- a/content/services/index.mdx
+++ b/content/services/index.mdx
@@ -2,25 +2,26 @@
title: Email, spam, and Gravatar
search:
tags: [email, spam, gravatar, avatars, akismet]
-description: "Send email, check submissions for spam, and read Gravatar profiles from any Zero handler without holding a credential."
+description: "Use email, spam checks, and Gravatar from the Zero handlers that receive each service."
---
-Every [Zero](/zero-runtime) handler context carries three platform services:
-`ctx.gravatar` reads public profiles, `ctx.spam` classifies visitor
-submissions, and `ctx.email` sends mail. The runtime brokers each call and
-attaches the credential outside your code. There is no mail password, spam
-API key, or Gravatar token to configure, and none can leak from a
-published bundle.
+Zero handler contexts carry platform services without exposing their
+credentials. `ctx.gravatar` reads public profiles, `ctx.spam` classifies
+visitor submissions, and `ctx.email` sends mail. There is no mail password,
+spam API key, or Gravatar token to configure.
What a handler receives follows what it can do. A query can be replayed or
re-run to refresh a live subscription, so a query receives only the calls
that have no effects outside the database:
-| Handler | `ctx.gravatar` | `ctx.spam` | `ctx.email` |
-| ---------- | -------------- | --------------------------- | ------------------- |
-| `query` | Yes | `check` only | No |
-| `mutation` | Yes | `check` plus corrections | Yes, transactional |
-| `action` | Yes | `check` plus corrections | Yes |
+Every handler gets `ctx.auth`, `ctx.env`, `ctx.gravatar`, `ctx.log`, and
+`ctx.spam.check`. Mutations and write endpoints add write access,
+`ctx.email`, spam corrections, and `ctx.invalidate`.
+
+| Handler | Database | `ctx.gravatar` | `ctx.spam` | `ctx.email` | `ctx.invalidate` |
+| --- | --- | --- | --- | --- | --- |
+| `query` or read endpoint | Read only | Yes | `check` only | No | No |
+| `mutation` or write endpoint | Read and write | Yes | `check` plus corrections | Yes | Yes |
`sf dev` brokers none of them. A local call to `ctx.email`, `ctx.spam`, or
`ctx.gravatar.profile` throws `zero_email_unavailable`,
@@ -108,7 +109,7 @@ The verdict has two fields. `spam` says the submission classifies as spam
and belongs in a review queue. `discard` flags blatant, pervasive spam that
is safe to drop outright instead of holding for review.
-Mutations and actions also get the corrections. Call
+Mutations and write endpoints also get the corrections. Call
`ctx.spam.reportSpam(submission)` when something passed that should not
have. Call `ctx.spam.reportHam(submission)` when something was held that
should not have been. Corrections usually run later, outside the original
@@ -135,20 +136,10 @@ mutations: {
},
```
-In a mutation, the send is accepted into the space's outbox on the
-handler's own transaction. A mutation that writes a row and queues its
-mail commits both or neither, and a throw after `send()` rolls back the
-row and the message together. When you want to scope that boundary
-explicitly, use `ctx.transaction(handler)`. Actions run outside a parent
-transaction because they exist to touch the world. Their sends go out when
-the handler succeeds, not atomically with database writes.
-
-Under `sf dev` the boundary is the whole invocation rather than the block you
-declared: a mutation or endpoint that throws rolls back everything it wrote,
-including writes made outside `ctx.transaction()`, and an error you catch
-yourself leaves that block's writes in place. Actions get no rollback at all
-locally. Publish the capsule when a test depends on the exact transaction
-boundary.
+In a mutation or write endpoint, the send enters the space's outbox in the
+handler's transaction. A handler that writes a row and queues mail commits
+both or neither. If the handler throws after `send()`, Zero rolls back the row
+and the message.
The message shape:
diff --git a/content/start/glossary.md b/content/start/glossary.md
index 81a5698..d935a9a 100644
--- a/content/start/glossary.md
+++ b/content/start/glossary.md
@@ -53,10 +53,8 @@ start with `bld_`. See [Build from Git](/publish/git).
### Capsule
-One Zero app: a schema plus its queries, mutations, actions, and endpoints,
-compiled into a single artifact. Everything in a capsule runs inside one
-database transaction, which is what separates Zero from Functions. See
-[Zero](/zero-runtime).
+One Zero app: a schema plus its queries, mutations, and endpoints, compiled
+into one artifact. See [Zero](/zero-runtime).
### CDN
diff --git a/content/zero-runtime/authentication.mdx b/content/zero-runtime/authentication.mdx
new file mode 100644
index 0000000..692fda6
--- /dev/null
+++ b/content/zero-runtime/authentication.mdx
@@ -0,0 +1,101 @@
+---
+title: Add authentication to a Zero app
+description: Show the current identity, add hosted sign-in, and enforce row ownership on the server.
+---
+
+Every Zero visitor has an identity. New visitors start as guests, so you can
+store per-visitor data before they sign in.
+
+## Add the sign-in control
+
+Import the authentication helpers in the client:
+
+```tsx
+import {
+ SignInWithGoogle,
+ signOut,
+ useAuth,
+} from "@spacefast/zero/client";
+import { Button, Spinner } from "@spacefast/zero/kit";
+```
+
+Render a control for the current state:
+
+```tsx
+function AuthControls() {
+ const auth = useAuth();
+
+ if (auth.isLoading) return ;
+ if (auth.isGuest) return ;
+
+ return (
+
+ {auth.displayName}
+
+
+ );
+}
+```
+
+`SignInWithGoogle` is the compatibility name for the hosted Gravatar sign-in
+button. `signOut()` returns the client to a guest identity. Hosted sign-in is
+unavailable under `sf dev`, so publish the capsule to test this flow.
+
+## Require sign-in
+
+A guest is an identity, not the absence of one: a signed-out visitor reaches
+your handler with a real `ctx.auth.userId` of its own. Testing that field
+therefore rejects nobody. Check `ctx.auth.isGuest` when a handler needs a
+signed-in visitor:
+
+```ts
+if (ctx.auth.isGuest) {
+ return json({ error: "Sign in required" }, { status: 401 });
+}
+```
+
+## Scope reads to the visitor
+
+Store the server identity with each user-owned row, then filter the query by
+`ctx.auth.userId`:
+
+```ts
+queries: {
+ todos: query(async (ctx) =>
+ ctx.db.todos
+ .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
+ .collect()
+ ),
+},
+```
+
+Declare the matching index on the table:
+
+```ts
+todos: table({
+ text: string(),
+ ownerId: string(),
+}).index("by_owner", ["ownerId"]),
+```
+
+## Check ownership before every write
+
+Do not trust a row ID from the client. Read the row in the mutation and compare
+its owner before you update or delete it:
+
+```ts
+mutations: {
+ renameTodo: mutation(async (ctx, id: string, text: string) => {
+ const todo = await ctx.db.todos.get(id);
+ if (!todo || todo.ownerId !== ctx.auth.userId) return;
+ await ctx.db.todos.update(id, { text: text.trim().slice(0, 160) });
+ ctx.invalidate("todos");
+ }),
+},
+```
+
+Client state is presentation, not authorization. Hiding a button from a guest
+does not protect a row. The query and each write handler must enforce ownership
+with `ctx.auth`.
diff --git a/content/zero-runtime/build.mdx b/content/zero-runtime/build.mdx
new file mode 100644
index 0000000..0cd8390
--- /dev/null
+++ b/content/zero-runtime/build.mdx
@@ -0,0 +1,193 @@
+---
+title: Build a Zero to-do app
+description: Build and publish a to-do app with a live query, mutations, and a read endpoint.
+---
+
+This tutorial builds a to-do app whose rows belong to the current visitor. The
+client updates after each mutation, and a read endpoint reports that the
+capsule is running.
+
+## Create the capsule
+
+Run:
+
+```bash
+sf init zero-todos --runtime zero
+cd zero-todos
+```
+
+Keep the generated `package.json`. Zero supplies its client, server, kit, and
+Preact modules when it builds the capsule.
+
+## Declare the entries
+
+Set `sf.jsonc` to:
+
+```jsonc
+{
+ "$schema": "https://spacefast.com/schemas/sf.json",
+ "name": "Zero todos",
+ "runtime": {
+ "kind": "zero",
+ "server": "server/index.ts",
+ "client": "client/index.tsx"
+ }
+}
+```
+
+Zero only accepts capsule source from `client/`, `server/`, and `shared/`.
+
+## Create the server capsule
+
+Replace `server/index.ts` with:
+
+```ts
+import {
+ boolean,
+ capsule,
+ endpoint,
+ mutation,
+ query,
+ string,
+ table,
+ text,
+} from "@spacefast/zero/server";
+
+export default capsule({
+ name: "Zero todos",
+ schema: {
+ todos: table({
+ text: string(),
+ done: boolean().default(false),
+ ownerId: string(),
+ }).index("by_owner", ["ownerId"]),
+ },
+ queries: {
+ todos: query(async (ctx) =>
+ ctx.db.todos
+ .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
+ .order("desc")
+ .collect()
+ ),
+ },
+ mutations: {
+ addTodo: mutation(async (ctx, text: string) => {
+ const cleanText = text.trim().slice(0, 160);
+ if (!cleanText) return;
+ await ctx.db.todos.insert({
+ text: cleanText,
+ done: false,
+ ownerId: ctx.auth.userId,
+ });
+ ctx.invalidate("todos");
+ }),
+ setDone: mutation(async (ctx, id: string, done: boolean) => {
+ const todo = await ctx.db.todos.get(id);
+ if (!todo || todo.ownerId !== ctx.auth.userId) return;
+ await ctx.db.todos.update(id, { done });
+ ctx.invalidate("todos");
+ }),
+ },
+ endpoints: {
+ status: endpoint(
+ { mode: "read", method: "GET", path: "/api/status" },
+ () => text("ok")
+ ),
+ },
+});
+```
+
+The endpoint declares a literal method, a literal path, and `mode: "read"`.
+Read endpoints and queries get read-only database access. Use `mode: "write"`
+when an endpoint must write.
+
+Each mutation calls `ctx.invalidate("todos")`. Zero then refreshes every live
+subscription opened under the `todos` query name.
+
+## Create the client
+
+Replace `client/index.tsx` with:
+
+```tsx
+import { createClient, useQuery } from "@spacefast/zero/client";
+import { Button, Card, Checkbox, EmptyState, Input } from "@spacefast/zero/kit";
+
+import type app from "../server/index";
+
+const api = createClient();
+
+export function App() {
+ const todos = useQuery(api.todos());
+ const addTodo = api.useMutation("addTodo");
+ const setDone = api.useMutation("setDone");
+
+ async function onSubmit(event: SubmitEvent) {
+ event.preventDefault();
+ const form = event.currentTarget as HTMLFormElement;
+ const text = String(new FormData(form).get("text") ?? "");
+ await addTodo(text);
+ form.reset();
+ }
+
+ return (
+
+
+
+ {todos.length === 0 ? (
+
+ )}
+
+
+ );
+}
+```
+
+`createClient()` carries the capsule's query names, mutation names,
+arguments, and results into the client without importing server code at
+runtime. The compiler rejects a misspelled handler name or an argument with the
+wrong type.
+
+## Run the app
+
+Start the capsule:
+
+```bash
+sf dev
+```
+
+Open the private URL from the command output. Do not remove its
+`#zero-dev-capability=...` fragment. Add a to-do item, then open another tab at the
+same private URL. Both tabs update after a mutation.
+
+Check the endpoint at `/api/status` on the same private URL. It returns `ok`.
+
+## Publish the app
+
+Run:
+
+```bash
+sf publish
+```
+
+Open the live URL from the publish receipt. The published capsule has durable
+database state, while the default `sf dev` database resets when the process
+stops.
diff --git a/content/zero-runtime/client.mdx b/content/zero-runtime/client.mdx
new file mode 100644
index 0000000..6bda845
--- /dev/null
+++ b/content/zero-runtime/client.mdx
@@ -0,0 +1,155 @@
+---
+title: Zero client API
+description: Use the typed client, live queries, pagination, mutations, routing, identity, storage, and error boundaries.
+---
+
+`@spacefast/zero/client` is the browser API for a Zero capsule. It connects the
+Preact client to the capsule's declared queries and mutations, the hosted
+identity, realtime updates, and the space's object store.
+
+## Create the typed client
+
+Import the capsule as a type, then pass that type to `createClient`:
+
+```tsx
+import { createClient, useQuery } from "@spacefast/zero/client";
+
+import type app from "../server/index";
+
+const api = createClient();
+
+function MessageList({ topic }: { topic: string }) {
+ const messages = useQuery(api.messages({ topic }));
+ return
{messages.length} messages
;
+}
+```
+
+The type-only import adds no server code to the client bundle. The client knows
+each query name, argument list, result, mutation name, and mutation argument
+list from the capsule type.
+
+The string form remains available for Lakebed compatibility:
+
+```tsx
+const messages = useQuery("messages", { topic });
+```
+
+Use the typed client in new code. It catches a renamed handler or a wrong
+argument before publish.
+
+## Run mutations
+
+Get a typed mutation function from the client:
+
+```tsx
+const sendMessage = api.useMutation("sendMessage");
+await sendMessage(roomId, body);
+```
+
+The returned promise resolves to the mutation result. A mutation error rejects
+the promise.
+
+## Paginate a query
+
+A paginated query accepts one object argument with a `pagination` field and
+returns `PaginationResult` on the server. The client supplies the pagination
+field:
+
+```tsx
+const messages = api.usePaginatedQuery(
+ "messages",
+ { roomId },
+ { initialNumItems: 20 },
+);
+
+return (
+ <>
+ {messages.page.map((message) => (
+
{message.body}
+ ))}
+ {!messages.isDone && }
+ >
+);
+```
+
+`page` contains all loaded rows. `continueCursor` is the next server cursor.
+`loadMore()` requests the next page, and `reset()` returns to the first page.
+
+## Route between pages
+
+The built-in router handles same-origin navigation without a page reload:
+
+```tsx
+import {
+ Link,
+ Route,
+ Router,
+ Routes,
+ useParams,
+} from "@spacefast/zero/client";
+
+function Project() {
+ const { id } = useParams() as { id?: string };
+ return
Project {id}
;
+}
+
+export function App() {
+ return (
+
+
+
+ Home
} />
+ } />
+ Not found} />
+
+
+ );
+}
+```
+
+Routes support literal segments, `:name` parameters, and `*` wildcards. The
+router also exports `navigate`, `useNavigate`, `useLocation`, and `useParams`.
+`Link` uses client navigation for same-origin URLs and normal browser
+navigation for external URLs.
+
+## Catch render errors
+
+Wrap a section with `ErrorBoundary` when the user can recover without
+reloading the whole app:
+
+```tsx
+import { ErrorBoundary } from "@spacefast/zero/client";
+
+ (
+
+ )}
+>
+
+;
+```
+
+The fallback receives the error and a function that resets the boundary.
+
+## Use identity and storage
+
+Use [`useAuth`, hosted sign-in, and sign-out](/zero-runtime/authentication) for
+the current visitor. `getIdentity()` reads the stored identity token metadata,
+and `decodeIdentityClaims()` decodes claims without verifying authorization.
+Server handlers must still enforce access with `ctx.auth`.
+
+The exported `storage` client supports `upload`, `get`, and `delete`. See
+[Storage](/storage) for object limits, authorization, and CLI operations.
+
+## Client exports
+
+| Group | Exports |
+| --- | --- |
+| Typed data | `createClient`, `useQuery`, `usePaginatedQuery`, `useMutation` |
+| Routing | `Router`, `Routes`, `Route`, `Link`, `navigate`, `useNavigate`, `useLocation`, `useParams` |
+| Authentication | `useAuth`, `SignInWithGoogle`, `signInWithGoogle`, `signOut`, `getIdentity`, `decodeIdentityClaims` |
+| Files | `storage` |
+| Recovery | `ErrorBoundary` |
diff --git a/content/zero-runtime/commands.mdx b/content/zero-runtime/commands.mdx
new file mode 100644
index 0000000..0fd9f8d
--- /dev/null
+++ b/content/zero-runtime/commands.mdx
@@ -0,0 +1,79 @@
+---
+title: Zero CLI commands
+description: List and call Zero Abilities, generate live types, and import Payload CMS or EmDash projects.
+---
+
+The `sf zero` command group works with a published Zero capsule and with local
+CMS projects. Use `--space` when the current directory is not linked to the
+target space.
+
+## List Abilities
+
+```bash
+sf zero abilities
+```
+
+The result lists every Ability published by the live capsule. The table shows
+the Ability name, category, read or write mode, and required authorization. Use
+`--json` to read each Ability's input and output schema.
+
+## Generate live types
+
+```bash
+sf zero types
+```
+
+The command writes `zero-types.ts` by default. The file contains the live
+content model types, the Ability catalog, the authorization required by each
+Ability, and a row type for each Zero database table.
+
+Commit the file, then check it in CI:
+
+```bash
+sf zero types --check
+```
+
+Use `--out ` to choose another destination.
+
+## Call an Ability
+
+```bash
+sf zero call content.posts.list
+```
+
+Pass input as inline JSON or from a file:
+
+```bash
+sf zero call content.posts.get --input '{"slug":"hello"}'
+sf zero call content.posts.save --input @post.json
+```
+
+The CLI mints a short-lived token, calls the Ability through the space's own
+WordPress REST API, and never prints the token. If the space has more than one
+machine credential, pass `--credential `.
+
+## Import another CMS
+
+Translate a Payload CMS project:
+
+```bash
+sf zero import payloadcms ../my-payload-app --out .
+```
+
+Translate an EmDash project:
+
+```bash
+sf zero import emdash ../my-emdash-site --out .
+```
+
+The import writes an authored Zero capsule and any supported content files.
+Payload CMS import refuses the whole write when the config contains a construct
+that Zero cannot represent. EmDash import reports mapped, renamed, downgraded,
+and refused data so omitted content is visible.
+
+Use `--capsule ` to choose the server module. Use `--force` only when you
+intend to replace existing files.
+
+Imported content declarations require the internal
+[managed content](/zero-runtime/managed-content) feature when you publish them.
+See the complete flags and JSON behavior in the [CLI reference](/cli).
diff --git a/content/zero-runtime/components.mdx b/content/zero-runtime/components.mdx
new file mode 100644
index 0000000..dd28428
--- /dev/null
+++ b/content/zero-runtime/components.mdx
@@ -0,0 +1,102 @@
+---
+title: Zero components and charts
+description: Reference every component, chart, and rendering helper bundled with the Zero client.
+---
+
+Zero bundles a Preact component kit and chart library. Import them from
+`@spacefast/zero/kit` and `@spacefast/zero/charts`. Do not add copies of these
+packages to the capsule.
+
+## Forms and actions
+
+| Export | Use |
+| --- | --- |
+| `Button` | Primary, secondary, ghost, destructive, link, and icon actions |
+| `Field` | A label, control, description, and validation message |
+| `Input` | Single-line input |
+| `TextArea` | Text input with multiple lines |
+| `Select` | Native select control |
+| `Label` | A form label |
+| `Checkbox` | Boolean checkbox |
+| `Switch` | Boolean switch |
+| `RadioGroup`, `RadioGroupItem` | One choice from a set |
+
+## Status and feedback
+
+| Export | Use |
+| --- | --- |
+| `Alert`, `AlertTitle`, `AlertDescription` | Informational, success, warning, or danger message |
+| `Badge` | Neutral, accent, success, warning, or danger label |
+| `EmptyState` | Empty result with an optional action |
+| `Spinner` | Indeterminate work |
+| `Skeleton` | Loading placeholder |
+| `Progress` | Determinate progress |
+
+## Content and layout
+
+| Export | Use |
+| --- | --- |
+| `Card` | Titled panel with optional subtitle and footer |
+| `Avatar` | Image or generated initials |
+| `Icon` | A bundled Lucide icon by name |
+| `Separator` | Horizontal or vertical divider |
+| `Kbd` | Keyboard input label |
+| `CodeBlock` | Highlighted code |
+| `Markdown` | Parsed and sanitized Markdown |
+| `Breadcrumb` | Path navigation |
+| `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`, `TableCaption` | Data table parts |
+
+## Disclosure and overlays
+
+| Export | Use |
+| --- | --- |
+| `Accordion`, `AccordionItem` | Collapsible sections |
+| `Tooltip` | Hover or focus description |
+| `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` | Tabbed content |
+| `Dialog` | Modal content |
+
+## Rendering helpers
+
+| Export | Result |
+| --- | --- |
+| `avatarInitials(name)` | Initials for an avatar fallback |
+| `highlightCode(code, language?)` | Highlighted code tokens |
+| `sanitizeUrl(url, kind?)` | A safe link or image URL, or `null` |
+| `parseMarkdownInline(source)` | Inline Markdown nodes |
+| `parseMarkdown(source, options?)` | Block Markdown nodes |
+| `markdownHeadingId(text)` | A heading ID |
+
+## Charts
+
+| Export | Use |
+| --- | --- |
+| `LineChart` | One or more line series |
+| `BarChart` | One or more bar series |
+| `Sparkline` | Compact trend line |
+| `StatTile` | Label, value, and optional trend |
+
+The chart module also exports `seriesColor`, `formatCompactValue`, `niceTicks`,
+`lineChartLayout`, `barChartLayout`, and `chartInk` for custom chart rendering.
+
+```tsx
+import { LineChart, StatTile } from "@spacefast/zero/charts";
+
+;
+;
+```
+
+Read [Style a Zero app](/zero-runtime/styling) for Tailwind compilation,
+semantic tokens, and `theme.json`.
diff --git a/content/zero-runtime/endpoints.mdx b/content/zero-runtime/endpoints.mdx
new file mode 100644
index 0000000..4f5f0b4
--- /dev/null
+++ b/content/zero-runtime/endpoints.mdx
@@ -0,0 +1,115 @@
+---
+title: Zero endpoints
+description: Declare HTTP routes, inspect requests, and return text, JSON, redirects, empty responses, or generated images.
+---
+
+An endpoint exposes an HTTP route from the capsule. Declare its method, path,
+and execution mode as literals so the compiler can build the route index.
+
+## Declare a read endpoint
+
+```ts
+import { endpoint, json } from "@spacefast/zero/server";
+
+endpoints: {
+ status: endpoint(
+ { mode: "read", method: "GET", path: "/api/status" },
+ (ctx, request) =>
+ json({
+ ok: true,
+ requestId: request.headers.get("x-request-id"),
+ userId: ctx.auth.userId,
+ }),
+ ),
+},
+```
+
+A read endpoint receives read-only `ctx.db`. It also receives `ctx.auth`,
+`ctx.env`, `ctx.log`, `ctx.gravatar`, and `ctx.spam.check`.
+
+## Declare a write endpoint
+
+Use `mode: "write"` when the endpoint changes data or uses a write-only
+service:
+
+```ts
+import { endpoint, json } from "@spacefast/zero/server";
+
+endpoints: {
+ createMessage: endpoint(
+ { mode: "write", method: "POST", path: "/api/messages" },
+ async (ctx, request) => {
+ if (ctx.auth.isGuest) {
+ return json({ error: "Sign in required" }, { status: 401 });
+ }
+ const input = await request.json<{ body: string }>();
+ const message = await ctx.db.messages.insert({
+ authorId: ctx.auth.userId,
+ body: input.body.trim(),
+ });
+ ctx.invalidate("messages");
+ return json(message, { status: 201 });
+ },
+ ),
+},
+```
+
+Write endpoints get the same context as mutations, including `ctx.email`, spam
+corrections, and `ctx.invalidate`.
+
+## Read the request
+
+| Value | Type or result |
+| --- | --- |
+| `request.method` | Uppercase HTTP method |
+| `request.path` | Request path |
+| `request.url` | Complete request URL |
+| `request.headers` | `get`, `has`, and `entries` |
+| `request.query` | `URLSearchParams` |
+| `request.text()` | UTF-8 request body |
+| `request.json()` | Parsed JSON body |
+| `request.bytes()` | Request body as `Uint8Array` |
+
+Validate untrusted input before writing it. A TypeScript type argument on
+`request.json()` does not validate the body at runtime.
+
+## Return a response
+
+| Helper | Default result |
+| --- | --- |
+| `json(value, options?)` | JSON body, status `200`, JSON content type |
+| `text(value, options?)` | Text body, status `200`, UTF-8 text content type |
+| `empty(options?)` | Empty body, status `204` |
+| `redirect(url, options?)` | Empty body, status `302`, `Location` header |
+
+Each `options` object accepts `status` and `headers`. Returning `null` or
+`undefined` also produces an empty `204` response. A string, number, or boolean
+becomes text. Another JSON-compatible value becomes JSON.
+
+## Generate an image
+
+`ImageResponse` renders a Preact element into an image:
+
+```ts
+import { h } from "preact";
+import { endpoint, ImageResponse } from "@spacefast/zero/server";
+
+endpoints: {
+ card: endpoint(
+ { mode: "read", method: "GET", path: "/card.png" },
+ () =>
+ new ImageResponse(
+ h(
+ "div",
+ { style: { background: "#111", color: "white", padding: 64 } },
+ "Project board",
+ ),
+ { width: 1200, height: 630 },
+ ),
+ ),
+},
+```
+
+The options accept `width`, `height`, `status`, and `headers`. Images default to
+1200 by 630 pixels and one hour of public caching. Set a `cache-control` header
+to replace that default.
diff --git a/content/zero-runtime/index.mdx b/content/zero-runtime/index.mdx
index 00d9613..1a5fe97 100644
--- a/content/zero-runtime/index.mdx
+++ b/content/zero-runtime/index.mdx
@@ -1,674 +1,89 @@
---
title: Zero
-description: Build apps with a database, storage, authentication, and typed server functions.
+description: Choose the full-stack runtime for apps built around shared data, identity, and live updates.
---
-Zero is Spacefast's full-stack runtime. One project contains a Preact client,
-a typed server capsule, a database schema, authentication, and storage. One
-publish command makes the whole app live as a normal space version.
-Zero is generally available.
+Zero is Spacefast's full-stack runtime. A Zero capsule puts a Preact client,
+server handlers, a typed database schema, authentication, and storage in one
+space. Zero is generally available. Managed content is internal and default
+off.
-Spacefast Zero is based on the capsule apps concept created by the
-[Lakebed](https://docs.lakebed.dev/) project, close enough that a Lakebed
-capsule compiles unchanged. See [Compare to Lakebed](#compare-to-lakebed).
+Choose Zero when the app is built around shared data. Queries keep the client
+current, mutations write to the database, and every visitor gets an identity.
+Choose [Functions](/functions) when you need a Web-standard `fetch` handler,
+your own framework, or direct control over request routing. Zero server code
+cannot call `fetch`.
-
-
- A schema in code, migrations on publish, live queries in the client.
-
-
- Guest identity for every visitor, hosted sign-in to upgrade it.
-
-
- Browser uploads with revocable read URLs and no credential setup.
-
-
- Tailwind classes in JSX, the platform kit, and SVG charts.
-
-
- Raw HTTP routes for webhooks, feeds, and generated images.
-
-
- Status, logs, backups, and rollbacks through the `sf` CLI.
-
-
-
-## Quick start
-
-
-
-
- ```bash
- sf init my-app --runtime zero
- cd my-app
- sf dev
- ```
-
- Open the private URL `sf dev` prints, not `http://localhost:4173`: the
- dev server is capability-gated, and the printed URL carries the capability
- in its fragment. `http://localhost:4173` on its own serves a page that
- tells you to go back and copy it.
-
- ```
- Open this private URL: http://127.0.0.1:4173/#zero-dev-capability=
- ```
-
- Edit `client/index.tsx` and `server/index.ts`. The local server reloads
- when you save a file. State is in memory unless you pass
- `--state-backend sqlite`.
-
-
-
-
- ```bash
- sf publish
- ```
-
- The publish command compiles the capsule, plans and applies the database
- migration, uploads the client, and activates the version.
-
-
-
-
-## Project layout
-
-
-
-- my-app/
- - client/
- - index.tsx
- - server/
- - index.ts
- - shared/
- - .env.server
- - sf.jsonc
- - package.json
-
-
-
-The runtime is explicit:
-
-```jsonc sf.jsonc
-{
- "$schema": "https://spacefast.com/schemas/sf.json",
- "name": "My app",
- "runtime": {
- "kind": "zero",
- "server": "server/index.ts",
- "client": "client/index.tsx",
- },
-}
-```
-
-Both entries are required. The publish fails when Spacefast cannot resolve an
-entry. Spacefast does not expose the source as static files.
-
-## The capsule
-
-A capsule is the default export of the Zero server entry. It declares the
-database schema and every callable server handler.
-
-```ts server/index.ts
-import { boolean, capsule, mutation, query, string, table }
- from "@spacefast/zero/server";
-
-export default capsule({
- name: "Todos",
- schema: {
- todos: table({
- text: string(),
- done: boolean().default(false),
- ownerId: string(),
- }).index("by_owner", ["ownerId"]),
- },
- queries: {
- todos: query(async (ctx) =>
- ctx.db.todos
- .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
- .order("desc")
- .collect()
- ),
- },
- mutations: {
- addTodo: mutation(async (ctx, text: string) => {
- await ctx.db.todos.insert({ text, done: false, ownerId: ctx.auth.userId });
- }),
- },
-});
-```
-
-Handler types:
-
-- **`query()`**: reads data and supports live client subscriptions.
-- **`mutation()`**: writes data.
-- **`action()`**: performs a one-shot server call.
-- **`endpoint()`**: exposes a raw HTTP method and path.
-
-Endpoint helpers include `json()`, `text()`, `empty()`, `redirect()`, and
-`ImageResponse` for images rendered from Preact nodes. Paths begin with `/`.
-Spacefast reserves the authentication and platform namespaces.
+## The shortest path
-Every handler receives `ctx` with:
-
-- **`ctx.auth`**: identity.
-- **`ctx.db`**: the declared tables.
-- **`ctx.env`**: server-only variables.
-- **`ctx.log`**: structured logging.
-- **`ctx.email`**, **`ctx.spam`**, **`ctx.gravatar`**: platform services with
- no credential to configure. See
- [Email, spam, and Gravatar](/services).
-
-Call named handlers from the client with hooks:
-
-```tsx client/index.tsx
-import { useAction, useMutation, useQuery } from "@spacefast/zero/client";
-
-const todos = useQuery("todos");
-const addTodo = useMutation<[text: string], void>("addTodo");
-```
-
-`useMutation()` and `useAction()` take the handler's argument tuple and its
-result type, so `addTodo("Buy milk")` is typed end to end and
-`addTodo(42)` fails to compile. `useQuery()` subscribes and
-re-renders when the result changes. For long lists, use `usePaginatedQuery()`
-and its `loadMore()` method. The client also exports `Router`, `Routes`,
-`Route`, `Link`, `useNavigate()`, `useParams()`, and `useLocation()` for
-client-side routes.
-
-## A complete app
-
-A guestbook that uses the whole runtime: a schema, a live query, a validated
-mutation with a spam check and Gravatar avatars, an action that emails a
-digest, a webhook, hosted sign-in, a photo upload to storage, two
-client-side routes, and a stats chart, styled with the platform kit and
-Tailwind classes. Two files are the whole app.
-
-
-
-```ts server/index.ts
-import { action, capsule, endpoint, json, mutation, query, string, table, text }
- from "@spacefast/zero/server";
-
-export default capsule({
- name: "Guestbook",
- schema: {
- entries: table({
- body: string(),
- photoUrl: string().default(""),
- avatarUrl: string().default(""),
- authorId: string(),
- authorName: string(),
- }),
- },
- queries: {
- entries: query(async (ctx) =>
- ctx.db.entries.withIndex("by_creation").order("desc").take(50)
- ),
- },
- mutations: {
- sign: mutation(async (ctx, body: string, photoUrl: string) => {
- const trimmed = body.trim().slice(0, 500);
- if (!trimmed) return;
- const verdict = await ctx.spam.check({
- content: trimmed,
- type: "comment",
- authorName: ctx.auth.displayName,
- });
- if (verdict.spam) return;
- await ctx.db.entries.insert({
- body: trimmed,
- photoUrl,
- avatarUrl: ctx.auth.email
- ? ctx.gravatar.avatarUrl(ctx.auth.email, { size: 64, default: "retro" })
- : "",
- authorId: ctx.auth.userId,
- authorName: ctx.auth.displayName,
- });
- }),
- },
- actions: {
- emailDigest: action(async (ctx) => {
- if (ctx.auth.isGuest || !ctx.auth.email) return;
- const recent = await ctx.db.entries
- .withIndex("by_creation").order("desc").take(10);
- await ctx.email.send({
- from: { email: "guestbook@example.com", name: "Guestbook" },
- to: ctx.auth.email,
- subject: `Your guestbook digest: ${recent.length} recent entries`,
- text: recent
- .map((entry) => `${entry.authorName}: ${entry.body}`)
- .join("\n"),
- });
- }),
- },
- endpoints: {
- incoming: endpoint({ method: "POST", path: "/webhooks/entries" },
- async (ctx, req) => {
- if (req.headers.get("x-webhook-secret") !== ctx.env.WEBHOOK_SECRET) {
- return text("unauthorized", { status: 401 });
- }
- const payload = await req.json<{ body: string }>();
- await ctx.db.entries.insert({
- body: payload.body,
- photoUrl: "",
- authorId: "webhook",
- authorName: "Webhook",
- });
- return json({ ok: true });
- }),
- },
-});
-```
-
-```tsx client/index.tsx
-import { Link, Route, Router, Routes, SignInWithGoogle, signOut, storage,
- useAction, useAuth, useMutation, useQuery } from "@spacefast/zero/client";
-import { Sparkline } from "@spacefast/zero/charts";
-import { Button, Card, EmptyState, Input, Spinner } from "@spacefast/zero/kit";
-import { useState } from "preact/hooks";
-
-type Entry = {
- id: string;
- body: string;
- photoUrl: string;
- avatarUrl: string;
- authorId: string;
- authorName: string;
- createdAt: string;
- updatedAt: string;
-};
-
-function SignForm() {
- const sign = useMutation<[body: string, photoUrl: string], void>("sign");
- const [draft, setDraft] = useState("");
- const [photo, setPhoto] = useState(null);
-
- return (
-
- );
-}
-
-function HomePage() {
- const entries = useQuery("entries");
- return (
-
- );
-}
-
-function StatsPage() {
- const auth = useAuth();
- const entries = useQuery("entries");
- const emailDigest = useAction<[], void>("emailDigest");
- const perDay = [...Array(7)].map((_, index) => {
- const day = new Date(Date.now() - (6 - index) * 86_400_000).toDateString();
- return entries.filter((entry) => new Date(entry.createdAt).toDateString() === day).length;
- });
- return (
-
-
- {auth.isAuthenticated ? (
-
- ) : null}
-
- );
-}
-
-export function App() {
- const auth = useAuth();
- return (
-
-
-
-
- {auth.isLoading ? (
-
- ) : auth.isGuest ? (
-
- ) : (
-
- )}
-
-
- } />
- } />
-
-
-
- );
-}
-```
-
-
-
-Everything on the page is one of the runtime's features doing its job:
-
-- **Live queries.** `useQuery("entries")` starts as an empty array and
- re-renders in every open tab whenever a mutation changes the rows,
- including rows the webhook writes. There is no cache wiring anywhere:
- write a row and every live query that reads it refreshes on its own.
-- **Validation lives on the server.** The `sign` mutation trims and caps the
- body. The client never writes rows directly.
-- **Spam checking is one call.** `ctx.spam.check()` classifies the entry
- before it is written. The runtime supplies the visitor's network evidence
- from the trusted request envelope. A browser cannot claim its own IP.
-- **Avatars come from Gravatar.** `ctx.gravatar.avatarUrl()` is synchronous
- and credential-free. The mutation stores the URL with the row.
-- **Actions send email.** The `emailDigest` action reads the same tables and
- sends through `ctx.email.send()` with no mail credential to configure, and
- `useAction()` wires it to a button. In a mutation, a send commits
- atomically with the writes. See
- [Email, spam, and Gravatar](/services).
-- **Sign-in is one component.** `SignInWithGoogle` upgrades the visitor's
- guest identity, and `signOut()` drops back to it.
-- **Uploads are one call.** `storage.upload(photo)` returns a read URL the
- row can keep. The photo is optional, and the form works without it.
-- **Routing is included.** `Router`, `Routes`, and `Link` give the app two
- pages without a dependency.
-- **Charts are included.** The stats page derives a week of counts from the
- same live query and hands them to `Sparkline`.
-- **The webhook is plain HTTP.** It checks a shared secret from
- `.env.server` before it writes, and answers with the endpoint helpers.
-
-Run it:
+Create a capsule, start it, and open the private URL that `sf dev` prints:
```bash
-sf init guestbook --runtime zero
-cd guestbook
-echo 'WEBHOOK_SECRET=pick-a-long-random-value' > .env.server
+sf init my-app --runtime zero
+cd my-app
sf dev
```
-Open the private URL `sf dev` prints. Signing the guestbook from the browser
-calls `ctx.spam.check`, which `sf dev` does not broker, so that path answers
-`zero_spam_unavailable` until you publish; the webhook writes without it.
-
-Post an entry from outside. Application routes need the capability, so send it
-as a bearer token alongside the webhook's own secret:
-
-```bash
-curl -X POST http://localhost:4173/webhooks/entries \
- -H "authorization: Bearer " \
- -H "content-type: application/json" \
- -H "x-webhook-secret: pick-a-long-random-value" \
- -d '{"body":"hello from a webhook"}'
-```
-
-The capability is the `zero-dev-capability` value in the URL `sf dev` printed.
-A published Space needs no capability: there the endpoint's own secret is the
-only check.
-
-`sf publish` makes it live: the capsule compiles, the migration applies,
-`.env.server` syncs as secret variables, and the version activates.
-
-## Styling
-
-Write Tailwind utility classes directly in JSX on the `class` attribute.
-There is nothing to install or configure. Zero compiles every class used
-anywhere in `client/`, `server/`, and `shared/` into the app's stylesheet,
-light and dark variants included. Classes nobody uses produce nothing. A
-`theme.json` at the project root adjusts the palette and typography the
-utilities compile against.
-
-The client also ships batteries:
-
-- **`@spacefast/zero/kit`**: the platform interface kit, with `Button`,
- `Card`, `Input`, `Badge`, `Tabs`, `Table`, `CodeBlock`, and more, plus
- `Icon` with the Lucide icon set.
-- **`@spacefast/zero/charts`**: `LineChart`, `BarChart`, and `Sparkline`,
- drawn as plain SVG with no charting library.
-
-```tsx client/index.tsx
-import { LineChart } from "@spacefast/zero/charts";
-import { Badge, Card } from "@spacefast/zero/kit";
-
-
- Live
-
-;
-```
-
-## Authentication
-
-Every Zero visitor starts with a stable guest identity: enough to own rows,
-return to them later, and keep anonymous users separate. Hosted sign-in
-upgrades the same browser session to an authenticated identity.
-
-```tsx client/index.tsx
-import { SignInWithGoogle, signOut, useAuth } from "@spacefast/zero/client";
-
-function AuthControls() {
- const auth = useAuth();
- if (auth.isLoading) return null;
- if (auth.isGuest) return ;
- return ;
-}
-```
-
-Hosted sign-in uses Gravatar. `SignInWithGoogle` is a compatibility alias that
-renders the "Sign in with Gravatar" button. `useAuth()` returns `userId`,
-`displayName`, `provider` (`"guest"` or `"gravatar"`), `isGuest`,
-`isAuthenticated`, `email`, `picture`, and `isLoading`.
-
-On the server, the same identity is `ctx.auth` in every handler. Check
-`ctx.auth.isGuest` when a handler requires sign-in. For row ownership, store
-`ctx.auth.userId` with the row, filter reads through an owner index, and
-verify that value before an update or delete. Never accept an owner id from
-client arguments:
-
-```ts server/index.ts
-setDone: mutation(async (ctx, id: string, done: boolean) => {
- const todo = await ctx.db.todos.get(id);
- if (!todo || todo.ownerId !== ctx.auth.userId) return;
- await ctx.db.todos.update(id, { done });
-}),
-```
-
-`sf dev` supplies a local guest identity, so authorization logic works the same
-locally and hosted.
-
-## Database
-
-Every Zero app has its own database. Declare the schema in the capsule, and
-the publish command compares the declaration with the live schema and applies
-the migration. Fields support `string()`, `boolean()`, and `id(table)`, with
-`.default(value)`. Every row also has `id`, `createdAt`, and `updatedAt`.
-
-Every table has a built-in `by_creation` index that reads rows in insertion
-order. Add `.index(name, fields)` for your own indexed reads with
-`.withIndex()`:
-
-```ts server/index.ts
-// One row, by id.
-const todo = await ctx.db.todos.get(id);
-
-// Newest rows first, via the built-in creation index.
-const latest = await ctx.db.todos.withIndex("by_creation").order("desc").take(20);
-
-// An owner's rows, via a declared index.
-const mine = await ctx.db.todos
- .withIndex("by_owner", (range) => range.eq("ownerId", ctx.auth.userId))
- .collect();
-```
-
-Indexed queries finish with `.collect()`, `.take(count)`, `.first()`, or
-`.paginate()`, and support `.order("asc")` and `.order("desc")`. Mutation
-contexts add `.insert()`, `.update(id, patch)`, and `.delete(id)`.
-
-Normal additive changes apply during `sf publish`. Destructive changes require
-an explicit migration command, because a publish cannot silently drop data. Express
-the rename or drop in the capsule schema first, then allow the planned
-migration to include it with the matching boolean flag:
-
-```bash
-sf db migrate --rename
-sf db migrate --drop
-```
-
-Inspect and back up:
-
-```bash
-sf db dump --table projects --limit 100
-sf db console
-sf db export --out ./backup.json
-```
-
-The export contains every declared table in a versioned JSON format and only
-replaces the destination file after the complete export succeeds. A rollback
-promotes older code. It does not rewind database rows. Check the current
-schema before you roll back across a migration.
-
-## Storage
-
-Zero client storage handles browser uploads without a separate storage
-credential. Objects are addressed by a random 128-bit id. Read URLs carry a
-runtime read key that Spacefast can rotate. Rotation immediately invalidates
-every URL minted under the old key.
-
-```tsx client/index.tsx
-import { storage } from "@spacefast/zero/client";
-
-const uploaded = await storage.upload(file);
-uploaded.url; // a read URL to store or render
-await storage.delete(uploaded.id);
-```
-
-Uploads and deletes require an identified visitor. Anonymous commenters are
-admitted where Comments admits them, against the daily anonymous budget. Only
-the uploader can delete an object. The space owner gets an inventory across
-uploaders with `sf storage ls` and can force-delete with
-`sf storage rm 0123456789abcdef0123456789abcdef --yes`. That delete is
-destructive and unrecoverable.
-
-Safety and limits: 5 MiB per object, a 200 MiB rolling daily budget for
-anonymous uploads per space, no empty uploads, and no executable or active web
-content (HTML, JavaScript, PHP, binaries). Total storage counts against the
-plan's storage limit.
-
-## Variables
-
-Keep secrets that belong to your app in `.env.server`. The guestbook's webhook
-uses one to authenticate incoming requests:
-
-```dotenv .env.server
-WEBHOOK_SECRET=replace_with_a_random_secret
-```
-
-The publish command syncs the file as secret variables, and server handlers
-read them through `ctx.env`. Platform services are already configured: use
-`ctx.email`, `ctx.spam`, and `ctx.gravatar` without SMTP or provider API keys.
-See [Variables](/publish/variables) for shared and space-level management.
-
-## Operate a live app
+The URL contains a capability in its fragment. The bare local host does not
+open the app. Edit the files under `client/`, `server/`, and `shared/`, then
+publish the capsule:
```bash
-sf runtime status
-sf logs runtime --follow
+sf publish
```
-Every space already has its own site, so a capsule publish onto an existing
-space is an ordinary publish, and nothing migrates. Code belongs to the
-version. Database rows and stored objects belong to the space and do not roll
-back with it.
-
-The compiled server bundle is capped at 768 KiB and the client bundle at
-8 MiB.
-
-## Compare to Lakebed
-
-A Zero project is a [Lakebed](https://docs.lakebed.dev/) capsule: the same
-layout (`server/index.ts` default-exporting `capsule()`, `client/index.tsx`
-exporting `App`, `shared/` for both sides), the same schema and handler API
-(`table()`, `string()`, `boolean()`, `id(table)`, queries, mutations,
-`endpoint()` with `json()` and `text()`), and the same client hooks
-(`useQuery`, `useMutation`, `useAuth`, the router, `SignInWithGoogle`,
-`signOut`). Tailwind classes in JSX work the same way.
-
-Compatibility is built into the compiler, not left to convention:
-
-- Imports from `lakebed/server` and `lakebed/client` resolve to the Zero
- runtime. `@spacefast/zero/server` and `@spacefast/zero/client` are the
- canonical names; both work.
-- `.env.lakebed.server` is read wherever `.env.server` is.
-
-To port a capsule, copy the project and swap the CLI: `sf dev` to run it
-locally and `sf publish` instead of a deploy.
+`sf publish` compiles both entries, applies the schema change, uploads the
+client, and activates the new version.
-What changes on Spacefast:
+## Build with Zero
-- Hosted sign-in is Gravatar, and `SignInWithGoogle` renders the "Sign in
- with Gravatar" button.
-- The project is a space: `sf.jsonc` config, versions, rollback, custom
- domains, and the rest of the platform.
-
-What Zero adds beyond Lakebed:
-
-- The platform kit and charts, covered in [Styling](#styling).
-- `empty()`, `redirect()`, and `ImageResponse` endpoint helpers.
+
+
+ Run a complete capsule with a schema, live query, mutations, and an HTTP
+ endpoint.
+
+
+ Use the typed client, live and paginated queries, mutations, routing,
+ identity, storage, and error boundaries.
+
+
+ Declare the capsule, database schema, queries, mutations, and handler
+ contexts.
+
+
+ Declare read and write routes, inspect requests, and return text, JSON,
+ redirects, empty responses, or generated images.
+
+
+ Use guest identity, hosted Gravatar sign-in, and server-side ownership
+ checks.
+
+
+ Use static Tailwind classes, semantic tokens, the component kit, and
+ charts.
+
+
+ Look up every component, chart, and rendering helper bundled with Zero.
+
+
+ List and call Abilities, generate live types, and import Payload CMS or
+ EmDash projects.
+
+
+ Declare collections, pages, file sync, and generated Abilities. This
+ feature is internal and default off.
+
+
+ Move a core Lakebed capsule to the canonical Spacefast imports.
+
+
+ Define tables and indexes, inspect rows, and manage schema changes.
+
+
+ Upload objects from the client and manage them with the CLI.
+
+
+ Send email, check spam, read Gravatar profiles, use server variables, and
+ write runtime logs.
+
+
diff --git a/content/zero-runtime/managed-content.mdx b/content/zero-runtime/managed-content.mdx
new file mode 100644
index 0000000..068ab9a
--- /dev/null
+++ b/content/zero-runtime/managed-content.mdx
@@ -0,0 +1,157 @@
+---
+title: Managed content in Zero
+description: Declare collections, pages, file sync, generated Abilities, and CMS imports in a Zero capsule.
+---
+
+::::warning[Internal and default off]
+Managed content requires the Spacefast team to activate it and a compatible
+runtime. General customer spaces cannot publish these declarations yet. The GA
+Zero runtime does not require managed content.
+::::
+
+Managed content compiles capsule declarations into a version-bound WordPress
+content model. Publishing activates that model. Promoting or rolling back a
+version activates the model stored with that version.
+
+## Declare collections
+
+Add literal `collections` to the capsule:
+
+```ts
+import { capsule } from "@spacefast/zero/server";
+
+export default capsule({
+ name: "Journal",
+ collections: {
+ posts: {
+ label: "Writing",
+ fields: {
+ deck: {
+ kind: "text",
+ label: "Deck",
+ required: true,
+ maxLength: 200,
+ },
+ body: {
+ kind: "blocks",
+ source: "content/posts/*.md",
+ },
+ },
+ },
+ projects: {
+ publicRead: true,
+ fields: {
+ status: {
+ kind: "enum",
+ values: ["draft", "shipped"],
+ },
+ lead: {
+ kind: "reference",
+ to: "posts",
+ },
+ cover: {
+ kind: "media",
+ multiple: true,
+ },
+ },
+ },
+ },
+});
+```
+
+WordPress supplies native `posts`, `pages`, and `media` collections. Declaring
+`posts` or `pages` adds fields to that native collection. Another collection
+becomes its own managed WordPress resource.
+
+A custom collection is private by default. Set `publicRead: true` to allow
+anonymous reads. Native `posts`, `pages`, and `media` stay public unless the
+declaration sets another policy.
+
+## Field kinds
+
+| Kind | Options |
+| --- | --- |
+| `text` | `multiline`, `maxLength` |
+| `number` | Common field options |
+| `boolean` | Common field options |
+| `datetime` | Common field options |
+| `blocks` | `allowed`, `source` |
+| `media` | `multiple` |
+| `reference` | Required `to`, optional `multiple` |
+| `enum` | Required `values`, optional `multiple` |
+
+Every field accepts `label` and `required`. A reference must name another
+declared collection.
+
+The compiler reads content declarations without running the server module.
+Use literal objects, arrays, strings, numbers, and boolean values. The compiler
+refuses helper calls, object spreads, variable references, and interpolated
+source paths.
+
+## Bind repository files
+
+A `source` on a `blocks` field binds Markdown or HTML files to that field:
+
+```ts
+body: {
+ kind: "blocks",
+ source: "content/posts/*.md",
+},
+```
+
+The glob can match files in one directory. The compiler creates one sync
+binding per file. Use `.md` for Markdown or `.html` for HTML. Another extension
+fails the build.
+
+Use `sync` when the field-level shorthand does not fit:
+
+```ts
+sync: {
+ homeBody: {
+ collection: "pages",
+ field: "body",
+ source: "content/home.md",
+ },
+},
+```
+
+The sync ledger reconciles the repository file and the WordPress field after
+publish.
+
+## Declare rendered pages
+
+Map a page name to a client component:
+
+```ts
+pages: {
+ projects: "client/ProjectsPage.tsx",
+ handbook: {
+ component: "client/HandbookPage.tsx",
+ path: "/docs/start",
+ title: "Handbook",
+ },
+},
+```
+
+The string form uses the page name as its route. `projects` serves
+`/projects`. The object form sets the component, route, and title explicitly.
+
+## Use generated Abilities and types
+
+The compiler publishes read and write Abilities for the content model. Inspect
+the live catalog and generate its types:
+
+```bash
+sf zero abilities
+sf zero types
+```
+
+Call an Ability by name:
+
+```bash
+sf zero call content.posts.list
+sf zero call content.posts.get --input '{"slug":"hello"}'
+```
+
+Read [Zero CLI commands](/zero-runtime/commands) for tokens, type checks, input
+files, and CMS import commands.
diff --git a/content/zero-runtime/move-from-lakebed.mdx b/content/zero-runtime/move-from-lakebed.mdx
new file mode 100644
index 0000000..57fc6d1
--- /dev/null
+++ b/content/zero-runtime/move-from-lakebed.mdx
@@ -0,0 +1,67 @@
+---
+title: Move a Lakebed capsule to Zero
+description: Move a core Lakebed capsule to Spacefast Zero imports and publish it with sf.
+---
+
+Spacefast supports the core Lakebed client and server APIs used by capsules
+with schemas, queries, and mutations. Move to the canonical Spacefast imports
+before you publish. Treat any Lakebed feature outside that core as a separate
+migration.
+
+## Put source in the Zero roots
+
+Keep the client entry under `client/`, the server entry under `server/`, and
+shared pure TypeScript under `shared/`.
+
+Declare both entries in `sf.jsonc`:
+
+```jsonc
+{
+ "$schema": "https://spacefast.com/schemas/sf.json",
+ "name": "My capsule",
+ "runtime": {
+ "kind": "zero",
+ "server": "server/index.ts",
+ "client": "client/index.tsx"
+ }
+}
+```
+
+## Replace the imports
+
+Replace `lakebed/server` or `@spacefast/compat-lakebed/server` with:
+
+```ts
+import { capsule, mutation, query, string, table } from "@spacefast/zero/server";
+```
+
+Replace `lakebed/client` or `@spacefast/compat-lakebed/client` with:
+
+```ts
+import { useMutation, useQuery } from "@spacefast/zero/client";
+```
+
+Use `@spacefast/zero/kit` and `@spacefast/zero/charts` for Spacefast client
+components. Do not keep compatibility imports in new code.
+
+## Run the capsule locally
+
+Run:
+
+```bash
+sf dev
+```
+
+Open the private URL that the command prints. Exercise every query and mutation
+that you moved.
+
+## Publish the capsule
+
+Run:
+
+```bash
+sf publish
+```
+
+Open the live URL from the publish receipt and repeat the checks against the
+published database and identity.
diff --git a/content/zero-runtime/server.mdx b/content/zero-runtime/server.mdx
new file mode 100644
index 0000000..4d8ff3e
--- /dev/null
+++ b/content/zero-runtime/server.mdx
@@ -0,0 +1,147 @@
+---
+title: Zero server API
+description: Declare a capsule, database schema, queries, mutations, and handler contexts.
+---
+
+`@spacefast/zero/server` defines the code that runs inside the Zero runtime.
+The compiler reads the capsule, compiles its handlers, and applies its database
+schema when you publish.
+
+## Declare a capsule
+
+Export one `capsule()` call from the configured server entry:
+
+```ts
+import { capsule } from "@spacefast/zero/server";
+
+export default capsule({
+ name: "Project board",
+ favicon: "/favicon.svg",
+ schema: {},
+ queries: {},
+ mutations: {},
+ endpoints: {},
+});
+```
+
+The GA declarations are `name`, `favicon`, `schema`, `queries`, `mutations`,
+and `endpoints`. `collections`, `pages`, and `sync` belong to
+[managed content](/zero-runtime/managed-content), which is internal and default
+off.
+
+## Define the database schema
+
+Use `table`, `string`, `boolean`, and `id`:
+
+```ts
+import { boolean, id, string, table } from "@spacefast/zero/server";
+
+const rooms = table({
+ name: string(),
+});
+
+const messages = table({
+ roomId: id("rooms"),
+ authorId: string(),
+ body: string(),
+ pinned: boolean().default(false),
+}).index("by_room", ["roomId"]);
+```
+
+Every row also has `id`, `createdAt`, and `updatedAt`. Do not declare those
+field names. The database also reserves the `by_creation` index.
+
+`id("rooms")` records the referenced table in the generated type. It does not
+add an automatic join.
+
+## Read rows
+
+A query gets read-only database access:
+
+```ts
+queries: {
+ messages: query(async (ctx, roomId: string) =>
+ ctx.db.messages
+ .withIndex("by_room", (range) => range.eq("roomId", roomId))
+ .order("desc")
+ .take(50)
+ ),
+},
+```
+
+| API | Result |
+| --- | --- |
+| `table.get(id)` | One row or `null` |
+| `table.withIndex(name, range?)` | A query builder for a declared index |
+| `range.eq`, `gt`, `gte`, `lt`, `lte` | An index range |
+| `query.order("asc" | "desc")` | The same builder with an order |
+| `query.collect()` | All matching rows |
+| `query.take(count)` | Up to `count` rows |
+| `query.first()` | The first row or `null` |
+| `query.paginate(options)` | One cursor page |
+
+For pagination, accept one object that contains `pagination`:
+
+```ts
+import type { PaginationOptions } from "@spacefast/zero/server";
+
+messages: query(
+ async (
+ ctx,
+ input: { roomId: string; pagination: PaginationOptions },
+ ) =>
+ ctx.db.messages
+ .withIndex("by_room", (range) => range.eq("roomId", input.roomId))
+ .order("desc")
+ .paginate(input.pagination),
+),
+```
+
+The matching client call uses `usePaginatedQuery`. See the
+[Zero client API](/zero-runtime/client#paginate-a-query).
+
+## Write rows
+
+A mutation gets `insert`, `update`, and `delete` in addition to every read
+operation:
+
+```ts
+mutations: {
+ pinMessage: mutation(async (ctx, id: string, pinned: boolean) => {
+ const message = await ctx.db.messages.get(id);
+ if (!message || message.authorId !== ctx.auth.userId) return null;
+
+ const updated = await ctx.db.messages.update(id, { pinned });
+ ctx.invalidate("messages");
+ return updated;
+ }),
+},
+```
+
+`insert(value)` returns the new row. `update(id, patch)` returns the updated row
+or `null`. `delete(id)` returns whether it deleted a row.
+
+Call `ctx.invalidate()` with the query names that changed. If a mutation calls
+no invalidation, Zero refreshes every live query on the page. Narrow
+invalidation avoids unnecessary reads.
+
+## Handler contexts
+
+| Context value | Query or read endpoint | Mutation or write endpoint |
+| --- | --- | --- |
+| `ctx.db` | Read only | Read and write |
+| `ctx.auth` | Yes | Yes |
+| `ctx.env` | Yes | Yes |
+| `ctx.log` | Yes | Yes |
+| `ctx.gravatar` | Yes | Yes |
+| `ctx.spam.check` | Yes | Yes |
+| Spam corrections | No | Yes |
+| `ctx.email` | No | Yes |
+| `ctx.invalidate` | No | Yes |
+
+Read [Email, spam, and Gravatar](/services) for the service contracts and local
+development limits. Read [Zero endpoints](/zero-runtime/endpoints) for request
+and response handling.
+
+Zero server code cannot call `fetch`. Use [Functions](/functions) when the
+server must call an arbitrary HTTP service.
diff --git a/content/zero-runtime/styling.mdx b/content/zero-runtime/styling.mdx
new file mode 100644
index 0000000..d3a2687
--- /dev/null
+++ b/content/zero-runtime/styling.mdx
@@ -0,0 +1,86 @@
+---
+title: Style a Zero app
+description: Use compiled Tailwind classes, semantic tokens, theme.json, the component kit, and charts.
+---
+
+Zero compiles Tailwind classes from your client source. Do not add a Tailwind,
+PostCSS, or CSS build step.
+
+## Write complete class names
+
+Use static class strings so the compiler can find them:
+
+```tsx
+const toneClass = urgent ? "bg-danger text-white" : "bg-surface text-ink";
+
+return
Status
;
+```
+
+Do not construct part of a class name, such as `bg-${tone}-500`. The compiler
+cannot discover that class.
+
+## Use semantic tokens
+
+Use `canvas` for the page, `surface` for panels, `ink` and `ink-muted` for
+text, `line` for borders, and `accent` for the main action. Zero also provides
+`success`, `warning`, and `danger`.
+
+```tsx
+
+
+
Last updated just now
+
+
+
+```
+
+These tokens adapt to light and dark color schemes. Common `shadcn` names such
+as `bg-background`, `text-muted-foreground`, and `border-border` map to the
+same roles.
+
+## Change tokens with theme.json
+
+Add `theme.json` at the capsule root to override tokens or add named presets:
+
+```json
+{
+ "version": 3,
+ "settings": {
+ "color": {
+ "palette": [
+ { "slug": "accent", "color": "#7a1fa2", "name": "Accent" },
+ { "slug": "brand", "color": "#b3592a", "name": "Brand" }
+ ]
+ },
+ "typography": {
+ "fontFamilies": [
+ { "slug": "display", "fontFamily": "Georgia, serif", "name": "Display" }
+ ]
+ }
+ }
+}
+```
+
+The example changes `bg-accent` and adds `bg-brand` and `font-display`.
+
+## Use the component kit
+
+Import pre-styled controls from the platform kit:
+
+```tsx
+import { Button, Card, Dialog, Input, Table } from "@spacefast/zero/kit";
+```
+
+The kit uses the same semantic tokens, so `theme.json` changes both your
+classes and the kit components.
+
+## Add charts and icons
+
+Use the Zero chart components for common charts:
+
+```tsx
+import { BarChart, LineChart, Sparkline, StatTile } from "@spacefast/zero/charts";
+```
+
+Zero also provides `recharts`, `lucide-react`, and Preact as platform imports.
+Import only what the client uses. Do not add an install step for these modules.
diff --git a/package.json b/package.json
index 563fd80..5c03e4d 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
"doctor": "blume doctor"
},
"dependencies": {
- "blume": "1.5.1"
+ "blume": "1.5.3"
},
"devDependencies": {
"@spacefast/sdk": "^0.0.24",