# DeepSpace Documentation # DeepSpace SDK Build real-time collaborative apps on Cloudflare Workers. Auth, storage, multiplayer, payments, and one-command deploys. DeepSpace is a full-stack SDK for shipping real-time apps on Cloudflare Workers. One package gives you authentication, SQLite-backed data sync, role-based permissions, presence, collaborative editing, channel-based messaging, file storage, AI chat, scheduled jobs, payments, and deploys to `.app.space` or your own custom domain. ## Get started in three commands ```bash npm create deepspace@latest my-app cd my-app npx deepspace dev start ``` The scaffold ships a Vite + React frontend, a Hono worker, file-based routing, Tailwind v4, a shadcn-style UI primitives kit, and six Durable Object classes wired into the SDK. Edit `src/` and refresh the browser. When you're ready to ship, run `npx deepspace deploy`. [Follow the quickstart →](/get-started/quickstart) ## The SDK Three entry points cover the surfaces most apps need. ```ts // Frontend (React) import { RecordProvider, useQuery, useMutations, useAuth } from 'deepspace' // Worker (Cloudflare Worker) import { RecordRoom, verifyJwt, CHANNELS_SCHEMA } from 'deepspace/worker' // Multi-user Playwright fixture (test files only) import { test, expect } from 'deepspace/testing' ``` | Entry point | What lives here | | ------------------- | ----------------------------------------------------------------------------- | | `deepspace` | React hooks, providers, auth UI, theming, payments client, integration client | | `deepspace/worker` | Durable Object base classes, schemas, JWT verification, AI helpers, metering | | `deepspace/testing` | Playwright fixture and account helpers for multi-user tests | A single CLI ships in the same package: `npx deepspace dev start`, `deploy`, `add`, `app files`, `secrets`, and more. See the [CLI overview](/cli-reference/overview). ## What you can build DeepSpace is built for apps where state is shared across users. The SDK ships the primitives so you don't write them yourself. * **Multiplayer apps.** Live cursors, presence, and per-collection RBAC out of the box. * **Collaborative editors.** Yjs-backed text fields and shapes synced over WebSocket. * **Messaging products.** Public channels, reactions, and read receipts as drop-in schemas. * **AI applications.** Streamed Claude, GPT, or Cerebras with multi-turn tool use over your records. * **SaaS with billing.** Subscriptions, one-time products, and refunds via Stripe Checkout. * **Internal tools.** Dashboards, trackers, and admin consoles with auth and storage built in. ## How this site is organized | Section | What you'll find | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | [Get started](/get-started/introduction) | Introduction, quickstart, installation, and a tour of the scaffolded project. | | [Core concepts](/concepts/architecture) | Architecture, the data model, permissions, real-time sync, and deployment. | | [Build with DeepSpace](/guides/authentication) | Step-by-step guides for the features you'll use most: auth, data, messaging, collaborative editing, presence, files, AI chat, and payments. | | [Worker & server](/guides/server-actions) | Server actions, scheduled jobs, external APIs, and custom Cloudflare bindings. | | [Going to production](/guides/custom-domains) | Custom domains and testing patterns. | | [SDK reference](/sdk-reference/overview) | Every export from `deepspace`, `deepspace/worker`, and `deepspace/testing`. | | [CLI reference](/cli-reference/overview) | Every CLI command, every flag. | | [Changelog](https://deep.space/changelog) | What shipped in each `deepspace` release. | ## Need help? * See what's new in each release in the [changelog](https://deep.space/changelog). * Join the [Discord](https://discord.gg/hcKSav5PpU) to ask questions and share what you've built. * Browse the source on [GitHub](https://github.com/deepdotspace). * Try the hosted app builder at [deep.space](https://deep.space). Source: /index.md --- # Introduction What DeepSpace is, who it's for, and why you'd use it. DeepSpace is an SDK for building real-time collaborative applications on Cloudflare Workers. The frontend is a standard Vite + React project. The backend is a Hono-based Cloudflare Worker. The SDK provides React providers and hooks, Durable Object base classes for sync and presence, a CLI, and a deploy pipeline that takes a project from `npm create` to a live URL. ## What you get out of the box When you scaffold a new app, you get a working starter with all of this already wired up: * **Authentication** - Better Auth with GitHub and Google OAuth. JWTs are minted by the DeepSpace platform's auth service; you don't run an auth server. * **Data sync** - App-owned SQLite-backed Durable Objects with WebSocket subscriptions, confirmed writes, and per-collection role-based access control. * **Real-time primitives** - Public-channel messaging, reactions, presence (live cursors, typing, viewport), Yjs-backed collaborative text and shapes, cron, and durable background jobs. * **File storage** - Per-app scoped R2 uploads. * **AI** - Streamed Claude, OpenAI, and Cerebras with multi-turn tool use, persistent chat history, and automatic context compaction. * **Payments** - Stripe subscriptions, one-time products, ad-hoc charges, refunds, and self-service billing portals. * **Scheduled tasks** - Per-app cron via a built-in Durable Object, with an admin monitor hook. * **Background jobs** - Durable, observable background work (AI generations, exports, fan-outs) with live progress, cancel, and retry - jobs survive isolate restarts. * **Third-party integrations** - A proxy that fronts 215+ external API endpoints (OpenAI, Anthropic, Stripe, Gmail, Calendar, LiveKit, finance, search, media, and more), with billing routed to the developer or the end-user. * **Custom Cloudflare bindings** - Declare Vectorize, KV, D1, Queues, Browser Rendering, Hyperdrive, or your own R2 buckets and the deploy pipeline auto-provisions them. * **Custom domains (optional)** - Apps ship on `.app.space` by default; bring your own `.com`, `.ai`, `.io`, or other domain via the CLI when you want a branded URL. ## Who it's for DeepSpace fits applications where state is shared across users or sessions. Common use cases: * Real-time updates across multiple browsers or devices * Per-user roles and access control on shared data * Collaborative editing (text, shapes, canvas) * Public channel-based messaging * A streamed AI chat surface with tool use * A billing model with subscriptions or one-time purchases * Cron jobs or background work without standing up new infrastructure Apps deploy to Cloudflare's global network, so requests terminate close to the user. ## What it isn't * **Not a no-code platform.** You write TypeScript and React. The CLI scaffolds, runs, and deploys - it doesn't generate features. * **Not a UI library.** Scaffolds include a shadcn-style primitives kit and 15 theme presets, but you own the design. * **Not a backend framework.** The worker is a normal Hono app; you can add routes, middleware, and bindings freely. * **Not a SaaS lock-in.** Apps deploy to Cloudflare Workers - you own the runtime, the data sits in your own Durable Objects, and you can eject at any time. ## Architecture at a glance A DeepSpace app has two parts. **Your worker** is a Cloudflare Worker that serves the React SPA, exposes API routes, and owns the per-app Durable Objects holding records, Yjs documents, canvases, presence, and scheduled jobs. **The DeepSpace platform** is a set of shared workers for auth, billing, integrations, files, and deployment that your worker calls over service bindings. You deploy your worker; the platform workers are managed by DeepSpace. See [Architecture](/concepts/architecture) for the full breakdown. ## Next steps * [Quickstart](/get-started/quickstart) - scaffold and deploy your first app. * [Installation](/get-started/installation) - prerequisites, account setup, and CLI login. * [Project structure](/get-started/project-structure) - a tour of the scaffolded files. * [Architecture](/concepts/architecture) - workers, Durable Objects, and scopes. Source: /get-started/introduction.md --- # Quickstart Scaffold, run, and deploy a real-time app. This guide takes you from `npm create` to a deployed app at `.app.space`. You'll need a supported Node line (see [installation](/get-started/installation)) and a GitHub or Google account for sign-in. For prerequisites and account setup, see [installation](/get-started/installation). For a tour of the files the scaffolder generates, see [project structure](/get-started/project-structure). ## 1. Scaffold an app The `create-deepspace` package bootstraps a new project with the SDK preinstalled, a [worker](/concepts/architecture) wired to a [Durable Object](/concepts/architecture#durable-objects), and a Vite-powered React frontend. ```bash npm create deepspace@latest my-app cd my-app ``` The scaffolder is non-interactive by default and prints a list of files it created. Pass `--interactive` if you want a prompt-driven wizard. If you are already signed in, the last thing the scaffolder does is register the app under **that** account - it names the account and the plane as it goes. See [which account the app registers under](/get-started/installation#which-account-the-app-registers-under) if more than one login could be active on this machine, or if you want to scaffold now and register later with `--no-register`. **App names are globally unique.** The name becomes your subdomain at `.app.space`, so it is claimed across all of DeepSpace - `my-app` in these examples is already taken. Pick something distinctive. A name someone else holds is refused at deploy time with `The name is taken by another app.` The name must be lowercase alphanumeric with optional dashes, 2-63 characters. You can rename later by editing `wrangler.toml` and deploying with `--rename`; your data, secrets, and collaborators travel with the app because they key to its immutable `DEEPSPACE_APP_ID`, not its name. ## 2. Start the dev server ```bash npx deepspace dev start ``` This runs Vite and the worker together and opens `http://localhost:5173` with hot-module reload. The CLI also writes an app-owner JWT and platform URLs to `.dev.vars`, so your local worker can call the live auth, storage, and integration services. Pass `--port` to run multiple apps in parallel: ```bash npx deepspace dev start --port 5180 ``` ## 3. Sign in to the CLI You'll need a DeepSpace account to deploy. One-time setup: ```bash npx deepspace auth login ``` This opens your browser for GitHub or Google OAuth and stores a long-lived session at `~/.deepspace/session`. DeepSpace accounts are OAuth-only - there is no password. The same session covers every app on the machine, and every later command refreshes from it on its own. Check status anytime with: ```bash npx deepspace auth whoami ``` Running from CI or an agent with no browser? Log in here, then hand that environment your `~/.deepspace/session` file - it acts as you. See [agents and CI](/cli-reference/overview#agents-and-ci). Manage your account, deployed apps, and billing at [dashboard.deep.space](https://dashboard.deep.space). ## 4. Make it yours Open the project in your editor. The scaffold already installs the [DeepSpace skill](https://github.com/deepdotspace/deepspace-skill) into `.agents/skills/deepspace/` and ships `AGENTS.md` + `CLAUDE.md` pointing coding agents at it. Claude Code, Cursor, and other agents pick up accurate type signatures and patterns with no extra setup. The pinned revision is recorded in `skills-lock.json`. Key files: * `src/pages/index.tsx` - the static landing page at `/`. Edit the hero copy. * `src/pages/(app)/home.tsx` - the signed-in home route at `/home`. * `src/schemas.ts` - collection schemas. Add a new collection alongside the seeded `users` and `settings`. * `src/pages/(app)/_layout.tsx` - the provider stack. Auth and realtime data are already wired. * `worker.ts` - the Hono worker. Add custom routes here. * `index.html` - change the `` and `data-theme` attribute to pick a preset. ### Where a page goes decides what it can do Routing is file-based, and the folder a page sits in determines which providers wrap it. There are three tiers: | Location | Providers | Use for | | ----------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------- | | `src/pages/*.tsx` | None | Static pages - marketing, legal, docs. No auth fetch, no WebSocket. | | `src/pages/(app)/*.tsx` | `DeepSpaceAuthProvider` + `RecordProvider` | Dynamic pages. `useAuth`, `useQuery`, `useMutations`, presence all work. Sign-in **not** required. | | `src/pages/(app)/(protected)/*.tsx` | The above, plus `<AuthGate>` | Pages that require sign-in. | Folders in parentheses are [generouted](https://github.com/oedotme/generouted) route groups - they apply a layout without appearing in the URL. So `(app)/(protected)/todos.tsx` serves at `/todos`. **The nesting is what gates the page, not the folder name.** `(protected)` has no special meaning to the router - it is gated only because `src/pages/(app)/(protected)/_layout.tsx` wraps its children in `<AuthGate>`. Creating your own `src/pages/(protected)/` at the top level makes a *different* group with no `_layout.tsx` in it. Such a page is **not** behind `<AuthGate>` - it is publicly reachable - and it also sits outside `(app)/_layout.tsx`, so `useQuery` and `useMutations` have no `RecordProvider` and will fail. Put gated pages under the existing `src/pages/(app)/(protected)/`. Try adding a `todos` [collection](/concepts/data-model). For the full schema reference, see [worker schemas](/sdk-reference/worker/schemas). ```ts // src/schemas/todos-schema.ts import type { CollectionSchema } from 'deepspace/schema' export const todosSchema: CollectionSchema = { name: 'todos', columns: [ { name: 'title', storage: 'text', interpretation: 'plain' }, { name: 'completed', storage: 'number', interpretation: { kind: 'boolean' } }, ], permissions: { member: { read: true, create: true, update: 'own', delete: 'own' }, admin: { read: true, create: true, update: true, delete: true }, }, } ``` ```ts // src/schemas.ts import type { CollectionSchema } from 'deepspace/schema' import { todosSchema } from './schemas/todos-schema' import { usersSchema } from './schemas/users-schema' import { settingsSchema } from './schemas/admin-schema' export const schemas: CollectionSchema[] = [usersSchema, settingsSchema, todosSchema] ``` Then read and write from the UI using [`useQuery`](/sdk-reference/client/records#usequery-t-collection-options) and [`useMutations`](/sdk-reference/client/records#usemutations-t-collection): ```tsx // src/pages/(app)/(protected)/todos.tsx import { useQuery, useMutations } from 'deepspace' type Todo = { title: string; completed: boolean } export default function TodosPage() { const { records, status } = useQuery<Todo>('todos', { orderBy: 'createdAt' }) const { create, put, remove } = useMutations<Todo>('todos') if (status === 'loading') return <p>Loading…</p> return ( <> <ul> {records.map((r) => ( <li key={r.recordId}> <input type="checkbox" checked={r.data.completed} onChange={(e) => put(r.recordId, { completed: e.target.checked })} /> {r.data.title} <button onClick={() => remove(r.recordId)}>Delete</button> </li> ))} </ul> <button onClick={() => create({ title: 'New todo', completed: false })}> Add </button> </> ) } ``` The page lives at `/todos` - generouted picks it up automatically. Because it sits under `(app)/(protected)/`, it inherits `<RecordProvider>` from `(app)/_layout.tsx` (so the hooks above work) and `<AuthGate>` from `(app)/(protected)/_layout.tsx` (so it requires sign-in). See [authentication](/guides/authentication). Save and the page hot-reloads. ## 5. Deploy Commit first - a deploy records the commit it ships: ```bash git add -A && git commit -m "add todos" npx deepspace deploy ``` The CLI bundles the worker, uploads it to Cloudflare Workers for Platforms, syncs any secrets from `.dev.vars`, and registers your subdomain. The [deployment](/concepts/deployment) reference covers the full pipeline. Within a few seconds, your app is live at: ``` https://<your-app>.app.space ``` An uncommitted worktree is refused with `dirty_worktree`. WIP commits are fine. If you genuinely want to ship without recording source lineage, pass `--no-push`. Visit the URL, sign in, and add a todo. Open the same URL in a second tab - edits sync between them instantly. ## Next steps You have a working app. Where to go from here: * [Authentication](/guides/authentication) - pick a public, gated, or mixed model. * [Collaborators](/guides/collaborators) - let a teammate deploy this app. * [Presence and cursors](/guides/presence-and-cursors) - show who's online and broadcast cursor positions. * [Payments](/guides/payments) - subscriptions and one-time products via Stripe. * [AI chat](/guides/ai-chat) - streamed Claude or GPT with tool use over your records. Source: /get-started/quickstart.md --- # Installation Set up your environment, create an account, and log in to the CLI. You don't install DeepSpace globally. The CLI ships inside the `deepspace` package, which is added to every new app by the scaffolder. The only thing you need on your machine ahead of time is Node.js. ## Prerequisites * **A supported Node.js line: 22.15+, 24, or 26.** The exact engines range is `>=22.15.0 <23 || >=24 <25 || >=26 <27` - the odd-numbered lines (23, 25) are excluded. Verify with `node --version`. If you need to switch versions, use [nvm](https://github.com/nvm-sh/nvm) or download an installer from [nodejs.org](https://nodejs.org/). * **npm, pnpm, or yarn.** Examples in these docs use npm; substitute your favorite if you prefer. * **A GitHub or Google account.** Used for signing in to the CLI and your deployed apps. There's no separate DeepSpace account. You do **not** need a Cloudflare account. DeepSpace deploys to a shared Workers for Platforms namespace operated by the platform. If you want a custom domain, you'll buy it through the CLI (Cloudflare Registrar or Porkbun under the hood) - no DNS setup on your side. ## Create a new app ```bash npm create deepspace@latest my-app cd my-app ``` This: 1. Downloads the latest `create-deepspace` package on demand (no global install). 2. Scaffolds a Vite + React app with the worker and Durable Objects pre-wired. 3. Installs `deepspace` and the starter's dependencies. 4. Initializes a git repository. 5. Runs `deepspace app init` to register the app and mint its id, then makes the initial commit. The scaffold takes about 30 seconds. You'll have a working app at the end, but `npx deepspace dev start` won't start until you've logged in. **The scaffolder version is the SDK version.** `create-deepspace@X` pins `deepspace` to **exactly** `X` in the new `package.json` - not `^X`. The generated files are written for that exact version, and [`app update`](/cli-reference/commands#app-update) guides the app-owned work needed to move them to a newer one; a caret meant a pinned scaffolder could produce an app running SDK X+1, so "built on X" was not reproducible. The install step prints what you actually got - `Dependencies installed — deepspace X` - read straight from the installed package rather than inferred from the scaffolder's own version. ### Which account the app registers under Step 5 registers the app under **whatever login the shell already holds, on the plane `DEEPSPACE_ENV` selects** (production when it is unset) - the scaffolder does not ask. It says so as it goes, naming the plane, the account, and the id: ``` App identity registered on production to you@example.com: app_01J… ``` Run `npx deepspace auth whoami` first if more than one account could be logged in on this machine. If registration is attempted and fails, the scaffolder prints the CLI's own refusal and **exits nonzero**, so a chained `npm create … && cd … && npx deepspace deploy` stops instead of deploying an app with no identity; recover with `npx deepspace auth login` and `npx deepspace app init` in the app directory (`app init` also makes the initial commit). To scaffold now and register later - the right move when the shell's login is not the intended owner - pass `--no-register`: ```bash npm create deepspace@latest my-app -- --no-register cd my-app npx deepspace auth login # as the intended owner npx deepspace app init # mints the id and makes the initial commit ``` Skipping is a deliberate choice, so it exits clean. Until `app init` runs the app has no id and no initial commit. ### Scaffold options ```bash # Interactive prompt-driven mode npm create deepspace@latest -- --interactive # Scaffold into the current directory (must be near-empty) mkdir my-app && cd my-app npm create deepspace@latest . # Scaffold without registering an app id npm create deepspace@latest my-app -- --no-register # Print help npm create deepspace@latest -- --help ``` The scaffolder is **non-interactive by default** (`--interactive` opts in), so there is no `--yes` flag - passing one is refused with an explanation rather than treated as unknown. "Near-empty" means the target directory contains only boilerplate (`.git`, `.gitignore`, `LICENSE`, any `*.md`, etc.). Anything else triggers a guardrail to prevent overwriting an existing project. The scaffold drops a `CLAUDE.md` at the project root that points coding agents at the [DeepSpace skill](https://github.com/deepdotspace/deepspace-skill). If you build with Claude Code, Cursor, or another agent, install it once so your assistant uses real SDK signatures instead of guesses: ```bash npx skills@latest add deepdotspace/deepspace-skill ``` ## Log in to the CLI Every CLI command that talks to the platform - `dev start`, `deploy`, `integrations invoke`, `test accounts`, and the rest - requires a session. Plan to log in once before your first `npx deepspace dev start`. ```bash npx deepspace auth login ``` This opens your default browser to sign in with **GitHub or Google** and waits for up to 10 minutes. DeepSpace accounts are OAuth-only - you never set a password, and your first sign-in creates the account. After you authorize, the CLI writes two files into `~/.deepspace/`: * `~/.deepspace/session` - the long-lived refresh token used to mint new JWTs. * `~/.deepspace/token` - the current short-lived JWT, refreshed automatically by other commands. The same session covers every DeepSpace app on the machine - you don't log in per project. It also covers coding agents you run here: they read the same file and act as you. To do that from a container or CI, see [agents and CI](/cli-reference/overview#agents-and-ci). Check your login state: ```bash npx deepspace auth whoami npx deepspace auth whoami --json # machine-readable ``` `whoami` refreshes the short-lived JWT on demand, so an expired token is handled silently. If `~/.deepspace/session` is missing, it prints "Not logged in." and exits 1. If the session can no longer be refreshed, it prints "Session expired." and exits 1. In either case, re-run `npx deepspace auth login`. Manage your account, deployed apps, billing, and earnings at [dashboard.deep.space](https://dashboard.deep.space). The CLI and the dashboard share one session - signing in once covers both. The session file holds a long-lived refresh token. Treat `~/.deepspace/session`, `~/.deepspace/token`, and your app's `.dev.vars` file as secret. Never commit any of them to version control. ## Update the SDK DeepSpace ships frequently. To pull in the latest SDK and CLI, run: ```bash npm install deepspace@latest ``` The CLI and the SDK are the same package - bumping the version updates both. ## Verify your setup You're ready to build when all of these succeed: ```bash node --version # v22.15+, v24.x, or v26.x npm create deepspace@latest -- --help npx deepspace --version npx deepspace auth whoami # prints your account, after `auth login` ``` ## Next steps * [Quickstart](/get-started/quickstart) - build and deploy your first app. * [Project structure](/get-started/project-structure) - tour of the scaffolded files. * [Changelog](https://deep.space/changelog) - what shipped in each SDK release. Source: /get-started/installation.md --- # Project structure A tour of the files in a scaffolded DeepSpace app. A scaffolded app is a Vite + React project with a Cloudflare Worker entry point. This page lists what `npm create deepspace@latest` ships and what each file is for. ## Top-level files | File | Purpose | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `worker.ts` | Hono worker and Durable Object class declarations. Edit to add custom routes or DO classes. | | `wrangler.toml` | Cloudflare config. Holds the app's immutable `DEEPSPACE_APP_ID` under `[vars]`, the `name` (subdomain label), DO bindings and migrations, and any custom bindings. | | `index.html` | HTML shell. Set `<html data-theme="...">` to pick a theme, update `<title>` and favicon. | | `vite.config.ts` | Vite + `@cloudflare/vite-plugin` + generouted config. | | `vitest.config.ts` | Vitest config for the `unit` suite. | | `eslint.config.js` | Flat ESLint config. | | `postcss.config.js` | PostCSS pipeline for Tailwind v4. | | `tsconfig.json` | TypeScript config. Strict mode, covers `src/` and `worker.ts`. | | `package.json` | Dependencies and the `deepspace`-backed npm scripts (`dev`, `build`, `test`, `deploy`). | | `deepspace.migrations.json` | App-owned ledger of which platform migration guides this project has applied and validated. `app update` reads it but never writes it. | | `skills-lock.json` | Pins the revision of the agent skill installed under `.agents/`. | | `AGENTS.md` | Agent-facing project brief, read by tools that follow the `AGENTS.md` convention. | | `CLAUDE.md` | Claude Code instructions - points at the bundled DeepSpace skill. | | `.gitignore` | Ignores `node_modules`, `dist`, `.wrangler`, `.dev.vars*`, `.deepspace`, and the generated `src/router.ts` / `src/modals.tsx`. | | `.dev.vars` | Local secrets (gitignored). Not in the template - `deepspace dev start` writes SDK-managed keys here on first run. | ### Agent tooling | Path | Purpose | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.agents/skills/deepspace/` | The bundled DeepSpace agent skill: `SKILL.md`, topic references (auth, deploy, schemas, payments, ...), and integration manifests. Installed by the scaffolder; version pinned in `skills-lock.json`. | | `.claude/launch.json` | Claude Code launch configuration (gitignored). | ## `src/` - application code ### Pages and layouts Routing is file-based via [generouted](https://github.com/oedotme/generouted): every file under `src/pages/` becomes a route. Folders in parentheses are **route groups** - they apply a layout without appearing in the URL. | File | Route | Purpose | | -------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `src/main.tsx` | - | Vite entry. Mounts `<Routes />` into `#root`. | | `src/pages/_app.tsx` | - | Outermost shell (toasts, error boundary). | | `src/pages/index.tsx` | `/` | **Static** landing page. No providers - no auth fetch, no WebSocket. | | `src/pages/[...all].tsx` | `*` | Catch-all 404. | | `src/pages/(app)/_layout.tsx` | - | The dynamic-app boundary. Mounts `DeepSpaceAuthProvider` → `AuthBoot` → `RecordProvider` → `RecordScope`, plus the `Navigation` chrome. | | `src/pages/(app)/home.tsx` | `/home` | Signed-in home. | | `src/pages/(app)/(protected)/_layout.tsx` | - | Wraps its children in `<AuthGate>`. | | `src/pages/(app)/(protected)/settings.tsx` | `/settings` | Gated settings page. | | `src/pages/(app)/(protected)/api-status.tsx` | `/api-status` | Gated platform-status page. | ### The three route tiers A page's folder decides its capabilities. The scaffold ships three tiers, and each carries a contract: | Tier | Location | Contract | | ------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Static** | `src/pages/*.tsx` | No providers - no auth fetch, no WebSocket. Data and auth hooks have nothing to read here and fail. The landing page lives in this tier and must keep that contract: `/` stays free of data and auth hooks so it renders instantly for signed-out visitors, crawlers, and tests. | | **Dynamic, signed-out capable** | `src/pages/(app)/*` | `DeepSpaceAuthProvider` and `RecordProvider` are mounted, so every auth, data, and presence hook works - but there is **no auth overlay**. These pages render for signed-out visitors too; never assume a user exists. | | **Gated** | `src/pages/(app)/(protected)/*` | Everything above, plus `<AuthGate>`: sign-in is required before children render, so pages here may assume a signed-in user. | Put each page in the lowest tier that satisfies it: static if it needs no data, `(app)/` if it needs data but should work signed out, `(app)/(protected)/` only when the page is meaningless without a user. **`(protected)` is not a magic name.** It gates its children only because `src/pages/(app)/(protected)/_layout.tsx` mounts `<AuthGate>`. A new `(protected)` group created anywhere else has no such layout and gates nothing - and outside `(app)/` it also has no `RecordProvider`, so data hooks fail there too. ### Components | File | Purpose | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/components/Navigation.tsx` | Top navigation with auth-aware controls. Reads entries from `src/nav.ts`. | | `src/components/ErrorScreen.tsx` | Full-screen error state used by the app boundary. | | `src/components/ui/` | Shadcn-style primitives: `Button`, `Dialog`, `Modal`, `Toast`, `Tabs`, `Select`, `Popover`, `DropdownMenu`, `Tooltip`, `Avatar`, `Badge`, `Checkbox`, `Switch`, `Input`, `Textarea`, `Label`, `SearchInput`, `EmptyState`. Barrel-exported from `ui/index.ts`. | | `src/lib/utils.ts` | The `cn()` class-merging helper the UI primitives use. | ### Data layer | File | Purpose | | ----------------------------- | ---------------------------------------------------------------------------------- | | `src/schemas.ts` | Exports the array of every collection schema in the app. | | `src/schemas/users-schema.ts` | The seeded `users` collection. | | `src/schemas/admin-schema.ts` | The seeded `settings` collection. | | `src/actions/index.ts` | Server actions - privileged worker functions called via `POST /api/actions/:name`. | | `src/constants.ts` | `APP_NAME`, `SCOPE_ID`, and role re-exports. | Schemas are imported by `worker.ts` and baked into the bundle at deploy time. There is no runtime schema registry - adding or changing a schema requires a redeploy. ### Worker-side route modules `worker.ts` stays thin by delegating to these: | File | Purpose | | ------------------------------- | ---------------------------------------------------- | | `src/server/http-routes.ts` | Plain HTTP routes on the app worker. | | `src/server/action-routes.ts` | Wiring for `src/actions/index.ts`. | | `src/server/realtime-routes.ts` | The `/ws/*` upgrade routes into the Durable Objects. | ### Theming and navigation | File | Purpose | | ---------------- | ---------------------------------------------------------------------------- | | `src/themes.ts` | Typed catalog of theme presets. | | `src/themes.css` | Per-theme CSS variable blocks. | | `src/styles.css` | Tailwind v4 entrypoint with the baseline `@theme` block. | | `src/nav.ts` | Top-nav entries. Add new pages here to make them appear in `Navigation.tsx`. | ### Feature surfaces These ship pre-wired with empty defaults. Edit to populate, or delete the file if the feature isn't needed. Use `npx deepspace add <feature>` to install additional surfaces. | File | Purpose | | ----------------------- | ----------------------------------------------------------------------------------------- | | `src/cron.ts` | Scheduled tasks for `AppCronRoom`. See [Scheduled tasks](/guides/scheduled-jobs). | | `src/jobs.ts` | Background-job handlers for `AppJobRoom`. See [Background jobs](/guides/background-jobs). | | `src/ai/tools.ts` | System prompt and tool allowlist for `/api/ai/chat`. | | `src/ai/chat-routes.ts` | Hono handlers for the AI chat endpoints. | | `src/integrations.ts` | Per-integration billing config (`developer` vs `user`). | | `src/subscriptions.ts` | Subscription plan manifest for Stripe billing. | | `src/products.ts` | One-time product manifest. | ## `public/` - static assets | File | Purpose | | -------------------- | -------------------------------------------------- | | `public/favicon.ico` | Default favicon. | | `public/_headers` | Cloudflare headers rules applied to static assets. | Large images and media should **not** live here or in Git - see [`deepspace app files`](/guides/file-uploads#large-files-and-media). ## `tests/` - Playwright and Vitest specs | File | Purpose | | ------------------------------- | ----------------------------------------------------------------------------------------- | | `tests/smoke.spec.ts` | App boot, navigation visibility, sign-in button presence, 404 route, console-error check. | | `tests/api.spec.ts` | API reachability and a WebSocket smoke check. | | `tests/collab.spec.ts` | Two-user multi-context sign-in via the `users` fixture from `deepspace/testing`. | | `tests/helpers/errors.ts` | Console-error capture helper. | | `tests/helpers/global-setup.ts` | Playwright `globalSetup` that warms up the auth worker. | | `tests/playwright.config.ts` | `baseURL`, `webServer`, `DEEPSPACE_PORT` plumbing. | Run with `npx deepspace test run`. See [Testing](/guides/testing). ## App identity `wrangler.toml` carries two different names, and the distinction matters: ```toml name = "my-app" # subdomain label - my-app.app.space [vars] DEEPSPACE_APP_ID = "app_01KZAA0A8YCS2..." # immutable identity APP_NAME = "my-app" ``` `DEEPSPACE_APP_ID` never changes. Data, secrets, collaborators, releases, and the cloud repo all key to it, which is why renaming an app (changing `name`, then `deploy --rename`) is safe: the URL moves, everything else travels. `deepspace app init` mints the id; `--new-id` deliberately forks the repo into a separate app. ## The Durable Object manifest `worker.ts` exports a `__DO_MANIFEST__` constant listing the DO classes the app uses: ```ts export const __DO_MANIFEST__ = [ { binding: 'RECORD_ROOMS', className: 'AppRecordRoom', sqlite: true }, { binding: 'YJS_ROOMS', className: 'AppYjsRoom', sqlite: true }, { binding: 'CANVAS_ROOMS', className: 'AppCanvasRoom', sqlite: true }, { binding: 'PRESENCE_ROOMS', className: 'AppPresenceRoom', sqlite: true }, { binding: 'CRON_ROOMS', className: 'AppCronRoom', sqlite: true }, { binding: 'JOB_ROOMS', className: 'AppJobRoom', sqlite: true }, ] as const satisfies DOManifest ``` Each entry has a `binding` (the env binding name your Hono routes look up), a `className` (the DO subclass exported from the same file), and `sqlite` (true for SQLite-backed DOs). This constant exists for TypeScript inference. The `Env` interface picks up the binding names from it automatically: ```ts interface Env extends DOBindings<typeof __DO_MANIFEST__> { // ...your secrets and custom bindings } ``` The deploy-time bindings and migrations are declared in `wrangler.toml` under `[durable_objects]` and `[[migrations]]`; keep the two in sync. Don't remove classes you no longer use without clearing their stored data first - records persist across deploys, and [`rollback`](/cli-reference/commands#rollback) refuses to drop a class without `--allow-do-deletion`. ## Next steps * [Quickstart](/get-started/quickstart) - build something with these files. * [Architecture](/concepts/architecture) - how everything fits together. * [Data model](/concepts/data-model) - collections, records, and the envelope shape. Source: /get-started/project-structure.md --- # Architecture How DeepSpace apps are structured, deployed, and connected to the platform. A DeepSpace app is a normal Cloudflare Worker. It serves your React SPA, exposes API routes, and owns the Durable Objects that hold its realtime data. Around your worker, the DeepSpace platform runs shared authentication, payments, integrations, file, and deployment services that your worker talks to over service bindings or HTTPS. **New to DeepSpace?** You don't need to understand the platform internals to build an app. Skip to the [Quickstart](/get-started/quickstart) and return here when you need to declare custom bindings or debug a deploy. ## The pieces A DeepSpace deployment has two halves: the worker you write, and the platform workers DeepSpace runs. **Your app worker** is a Cloudflare Worker compiled from `worker.ts` and the [Vite](https://vite.dev/) build of `src/`. It serves the SPA, handles HTTP and WebSocket routes, and owns the per-app Durable Objects. You deploy it with `npx deepspace deploy`; it lives at `<name>.app.space`. **The platform workers** are managed by DeepSpace; you never deploy or configure them. Your worker talks to three of them at runtime (auth, API, platform) via the helpers in [Talking to platform workers](#talking-to-platform-workers); the deploy and dispatch workers sit outside your request path. | Platform worker | Responsibility | | ------------------- | -------------------------------------------------------------------------------------------------- | | **Auth worker** | Better Auth integration, OAuth flows, JWT issuance (ES256, 5-minute lifetime). | | **API worker** | Stripe billing, the integration proxy (215+ third-party endpoints), user profiles, usage tracking. | | **Platform worker** | App-file gateway, screenshot service, health endpoint, and the public app-registry projection. | | **Deploy worker** | Receives `deepspace deploy` uploads; provisions custom bindings; manages subdomains. | | **Dispatch worker** | Routes `*.app.space` traffic to the correct deployed app via Workers for Platforms. | ## Your worker The scaffolded worker is a [Hono](https://hono.dev/) app. Its main routes: | Route | What it serves | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `GET /ws/:roomId` (and variants) | WebSocket upgrades for Durable Object rooms - records, Yjs, canvas, presence, cron, jobs | | `/api/auth/*` | Proxied to the platform's auth worker (sign-in, OAuth callback, sign-out) | | `/api/integrations/*` | Proxied to the platform's API worker - any method, including `DELETE /oauth/:provider/disconnect` | | `POST /api/actions/:name` | Server actions defined in `src/actions/index.ts` | | `/api/ai/*` | Streamed chat (`POST /chat`) and chat CRUD (`POST/PATCH/DELETE /chats[/:id]`), defined in `src/ai/chat-routes.ts` | | `/api/files/*` | Scoped R2 file storage, proxied to the platform worker (`?scope=app` or per-user) | | `/_deepspace/*` | Allowlisted same-origin proxy for SDK billing hooks (subscriptions, charges) | | `/api/debug/*` | RecordRoom debug endpoints, gated on `ALLOW_DEBUG_ROUTES` (set by `deepspace dev start` / `test run`, never in production) | | Everything else | Static SPA assets (the Vite build output) | The worker runs on Cloudflare Workers for Platforms, which means each deployed app is isolated in its own namespace. Your app's URL is `<wrangler.toml name>.app.space`. ### Platform-reserved routes (`run_worker_first`) `wrangler.toml` ships a `run_worker_first` list - paths that reach your worker before the asset layer answers, so an unmatched `/api/*` call gets a real 404 instead of the SPA shell. The scaffold's baseline: ```toml run_worker_first = ["/api/*", "/ws/*", "/internal/*", "/v1/*", "/_deepspace/*", "/llms.txt", "/llms-full.txt", "/.well-known/mcp", "/.well-known/mcp.json", "/.well-known/mcp/*"] ``` This baseline is platform-reserved: `deploy` strips these entries from your list and the deploy worker re-adds them from its own baseline, so apps can append routes but can never drop these. `/v1/*` is reserved so OpenAI-compatible routes you mount in `worker.ts` resolve ahead of the SPA fallback; `/_deepspace/*` is the billing-hook proxy the subscriptions and charges hooks call; the `llms.txt` and `.well-known/mcp` entries keep the machine surfaces from answering with your homepage. Append your own prefixes (`/oauth/*`, `/preview/*`, …) when you mount routes outside the baseline - `deploy` forwards the extras and merges them into the deployed config. ## Durable Objects State that needs to be **shared** - across users, across tabs, in real time - lives in a Durable Object. The scaffold ships six DO classes; each is an SDK base class subclassed in `worker.ts`: | Class | Purpose | WebSocket route | | -------------- | ----------------------------------------------------------- | ----------------------- | | `RecordRoom` | SQLite-backed records (your collections) | `/ws/:roomId` | | `YjsRoom` | Per-document Yjs CRDT state | `/ws/yjs/:docId` | | `CanvasRoom` | Collaborative canvas shapes + viewports | `/ws/canvas/:docId` | | `PresenceRoom` | Cursors, typing indicators, viewports | `/ws/presence/:scopeId` | | `CronRoom` | Scheduled task scheduler + history | `/ws/cron/:roomId` | | `JobRoom` | Durable background jobs (AI generations, exports, fan-outs) | `/ws/jobs/:roomId` | You can add your own Durable Object classes or subclass these with custom behavior. A DO instance is identified by its name. Same name = same instance with the same state; a different name is a different instance with its own. ## Scopes A scope is a namespaced identifier that determines which DO instance you're talking to. | Scope | What it represents | Hosted on | | -------------------- | -------------------------------------------------------- | ----------- | | `app:<APP_ID>` | Your app's main RecordRoom | Your worker | | `chat:<channelId>` | An optional isolated room for one app-owned chat channel | Your worker | | Any app-defined name | Another isolated room owned and authorized by your app | Your worker | Scope ids key to the immutable `DEEPSPACE_APP_ID`, never the app name - names are mutable URL leases, ids are immutable, so a rename never strands your data. The scaffold exports `` SCOPE_ID = `app:${APP_ID}` `` from `src/constants.ts` and the worker keys its DOs the same way (`app:${env.DEEPSPACE_APP_ID}`). Your `RecordScope` provider mounts this scope; `useQuery` / `useMutations` operate against it. There are no user-scoped DOs, by design. Don't mint a `user:<id>` room per user - user-scoped data lives in app DOs as collections with RBAC filtering (`read: 'own'` gives each user their own slice of one room). The historical platform-owned `workspace:*`, `dir:*`, and `conv:*` RecordRoom namespaces have been removed. If two apps need to share data, expose an authenticated API from the app that owns it instead of placing both apps inside a global database. ## Talking to platform workers The SDK exposes three fetch helpers in `deepspace/worker` for addressing platform services. `apiWorkerFetch` and `platformWorkerFetch` prefer a Cloudflare service binding when one is configured and fall back to an HTTPS URL otherwise, so the same code path works in production and under `deepspace dev start`. `authWorkerFetch` is URL-only by design. ```ts import { authWorkerFetch, apiWorkerFetch, platformWorkerFetch } from 'deepspace/worker' ``` | Helper | Signature | Use it for | | --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `authWorkerFetch` | `(env, path, init?) => Promise<Response>` | Sign-in flows, JWT issuance, session cookies. URL-only by design - there is no auth-worker service binding. | | `apiWorkerFetch` | `(env, path, init?) => Promise<Response>` | Integration proxy, billing, subscriptions, charges. | | `platformWorkerFetch` | `(env, pathOrRequest, init?) => Promise<Response>` | App files and platform HTTP services. Accepts a `Request` when a route needs to forward the original request verbatim. | Calling `c.env.PLATFORM_WORKER.fetch(...)` directly works in production but breaks under `deepspace dev start`, where the binding is absent and the CLI writes a `PLATFORM_WORKER_URL` fallback into `.dev.vars` for the helpers to pick up. ## Security model - WebSocket identity Durable Objects trust whatever identity the request URL carries. Verifying that identity is the worker's job - it strips anything the client sent, then re-applies it from a verified JWT. The SDK does this at two entry points: **Per-app WebSocket route (`wsRoute`)** - the scaffold ships a local `wsRoute` helper in `src/server/realtime-routes.ts` (it is not an SDK export). It strips `userId`, `userName`, `userEmail`, `userImageUrl`, `role`, and `token` from the URL on every upgrade, then re-applies identity only from a verified JWT. Three states are possible: * **No token** → anonymous (DO assigns `anon-<uuid>`) * **Invalid token** → 401 * **Valid token** → identity = JWT `sub` / `name` / `email` / `image` Anonymous connections are session-local guests, not accounts: the room assigns the `anon-<uuid>` id and a non-identifying label, omits email, image, and chosen role, and accepts only that connection's ephemeral presence state. Never persist an `anon-<uuid>` identity or promote it into an account. **Never put identity in WebSocket URLs or `/api/*` headers.** The scaffold's `wsRoute` helper strips them; identity always comes from the JWT subject. There is no client-side override. **`/ws/yjs/:docId` is special.** It is the only `/ws/*` route that requires a verified JWT (401 without one) and resolves a per-doc role (`admin` / `member` / `viewer`) from the `documents` collection's `ownerId`, `editors`, and `collaborators` fields - 403 when the caller has none. Do not collapse it into a bare `wsRoute` call. See [YjsRoom authentication and roles](/guides/collaborative-editing#yjsroom-authentication-and-roles). ## Build & deploy pipeline `npx deepspace deploy` performs these steps in order: Build with Vite `npx vite build` runs the Cloudflare Workers Vite plugin, producing the client assets and the worker bundle in a single pass, plus a normalized `wrangler.json` under `.wrangler/deploy/`. Extract manifests The CLI reads the DO bindings and custom bindings (R2, KV, D1, Vectorize, AI, …) out of the build output; user secrets come from the app's remote secrets store - deploy never reads `.dev.vars` (see [Deployment](/concepts/deployment)). Validate the binding manifest `validateBindingManifest` checks custom bindings against allowed types and reserved names. Reserved or duplicate names abort the deploy with a file-pointing error. Upload to the deploy worker Worker bundle, assets, DO manifest, custom bindings, and user secrets are POSTed as a single FormData to the deploy worker. Auto-provision resources Server-side, the deploy worker creates any binding declared with `id = "auto"` (or `bucket_name = "auto"`, etc.) on the platform Cloudflare account on first deploy. Register subdomain and dispatch route The worker is loaded into the dispatch namespace under `<name>.app.space`; user secrets become `secret_text` bindings on the deployed worker. The dispatch worker routes incoming traffic to your worker's isolate. Sync subscription plans and products (optional) If `src/subscriptions.ts` or `src/products.ts` exist, the CLI bundles them with esbuild and posts the declarations to the API worker. Skipped silently when the files are absent. ## Next steps * [Data model](/concepts/data-model) - collections, records, and how data is shaped. * [Permissions](/concepts/permissions) - role-based access control on collections. * [Real-time sync](/concepts/realtime-sync) - how WebSocket sync works under the hood. * [Deployment](/concepts/deployment) - what happens when you run `deploy`. Source: /concepts/architecture.md --- # Data model Collections, records, the envelope shape, and how data flows from your worker to the client. DeepSpace stores app data in **collections** - typed tables backed by SQLite inside a Durable Object. Each collection is declared in a schema, baked into your worker at deploy time, and exposed to the client through [`useQuery`](/sdk-reference/client/records#usequery-t-collection-options) and [`useMutations`](/sdk-reference/client/records#usemutations-t-collection) hooks. ## Collections and records A collection is a named table with typed columns. A record is one row, wrapped in an envelope that carries metadata. The SDK exports this shape as `RecordData<T>`: ```ts type RecordData<T> = { recordId: string // unique ID - the client generates this on create data: T // your user-defined fields createdBy: string // userId of the creator createdAt: string // ISO timestamp updatedAt: string // ISO timestamp } ``` Your own fields live under `.data`. When you query a `todos` collection: ```tsx const { records } = useQuery<{ title: string; completed: boolean }>('todos') records[0].data.title // "Buy milk" records[0].recordId // "1714000000000-k3f9x2a" records[0].title // undefined - common bug ``` Access fields under `r.data.<field>`, not `r.<field>`. Use `r.recordId` to pass into `put` and `remove`. ## Defining a schema Schemas live under `src/schemas/`, with the full list exported from `src/schemas.ts`. Every schema has `name`, `columns`, and `permissions`: ```ts // src/schemas/items-schema.ts import type { CollectionSchema } from 'deepspace/schema' export const itemsSchema: CollectionSchema = { name: 'items', columns: [ { name: 'title', storage: 'text', interpretation: 'plain' }, { name: 'status', storage: 'text', interpretation: { kind: 'select', options: ['draft', 'published'] } }, { name: 'priority', storage: 'number', interpretation: 'plain' }, ], visibilityField: { field: 'status', value: 'published' }, permissions: { viewer: { read: 'published', create: false, update: false, delete: false }, member: { read: true, create: true, update: 'own', delete: 'own' }, admin: { read: true, create: true, update: true, delete: true }, }, } ``` Register it: ```ts // src/schemas.ts export const schemas = [usersSchema, settingsSchema, itemsSchema] ``` Schemas are **baked in at deploy time** - there is no runtime schema registry. Adding or changing a schema requires a redeploy. The two scaffold schemas have different standing. `usersSchema` is **required**: the SDK's user hooks and the auth user-row writes expect a `users` collection built on the `USERS_COLUMNS` baseline - add columns to it, but never rename, replace, or drop it. `settingsSchema` is **scaffold starter only** - an admin-only key/value store that no SDK feature depends on. Customize its columns freely, or remove it entirely if your app doesn't need an admin settings store. ## Column types Every column has a `storage` type and an `interpretation`: | `storage` | What it holds | | ---------- | ------------------------------------------------------------------------------------------------- | | `'text'` | Strings, IDs, ISO timestamps, JSON blobs | | `'number'` | Integers, floats, booleans (stored as `0`/`1`), and date/datetime values (stored as Unix seconds) | `storage` picks the underlying SQLite column type - `'text'` becomes a `TEXT` column, `'number'` becomes a `REAL` column. Pick `'number'` for any column you want to range-query, sort numerically, or store as an integer, float, boolean, or Unix timestamp; pick `'text'` for everything else. `interpretation` tells the SDK how to encode/decode the value. It is either the bare string `'plain'` or an object with a `kind` discriminator: | Interpretation | Typical `storage` | Notes | | --------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `'plain'` | either | Pass-through. Use this for raw numbers and free-form text. | | `{ kind: 'currency', symbol, decimals }` | `'number'` | Strips currency symbols / commas on write. | | `{ kind: 'date', format? }` | `'text'` or `'number'` | ISO date string in text; Unix seconds in number. | | `{ kind: 'datetime', format? }` | `'text'` or `'number'` | Same coercion as `date`. | | `{ kind: 'boolean', trueLabel?, falseLabel? }` | `'number'` | Stored as `0` / `1`. | | `{ kind: 'percent', decimals? }` | `'number'` | Accepts `"42%"` strings; stores `0.42`. | | `{ kind: 'select', options: string[] }` | `'text'` | Constrained enum. | | `{ kind: 'multiselect', options: string[] }` | `'text'` | Constrained enum, multiple values. Stored as text - pass a pre-joined string (or use `{ kind: 'json' }` if you want array round-tripping). | | `{ kind: 'url' }` | `'text'` | URL string. | | `{ kind: 'email' }` | `'text'` | Email string. | | `{ kind: 'json' }` | `'text'` | Auto `JSON.stringify` on write, auto `JSON.parse` on read. | | `{ kind: 'reference', targetTable, displayColumn }` | `'text'` | Foreign-key-style pointer to another collection. | Import the union as `ColumnInterpretation` from `deepspace/schema` if you want the full type for your own helpers: ```ts import type { ColumnInterpretation } from 'deepspace/schema' ``` Use the object form for any kind that takes options (`currency`, `select`, `multiselect`, `reference`). The bare-string form is recommended only for `'plain'` - kinds without required fields technically resolve too, but the object form keeps the schema readable. There is no `'number'` interpretation - express numeric columns as `storage: 'number'` with `interpretation: 'plain'`. ```ts { name: 'tags', storage: 'text', interpretation: { kind: 'json' } } // On write: pass the array directly - mutations.create({ tags: ['a', 'b'] }) // On read: record.data.tags is already an array - don't JSON.parse ``` ## Hooks: `useQuery` and `useMutations` ```tsx import { useQuery, useMutations } from 'deepspace' type Item = { title: string; status: 'draft' | 'published' } function ItemList() { const { records, status } = useQuery<Item>('items', { where: { status: 'published' }, orderBy: 'createdAt', orderDir: 'desc', limit: 50, }) const { create, put, remove } = useMutations<Item>('items') // create(data: Item) → Promise<string> (the new recordId) // put(id, patch: Partial<Item>) → Promise<void> (merge into existing row) // remove(id) → Promise<void> } ``` The hooks subscribe to a WebSocket the moment they mount and stream updates in real time. When any user (including you) creates, updates, or deletes a record, every open client sees the change within milliseconds. ### Fire-and-forget vs confirmed mutations `create` / `put` / `remove` send the write without waiting for an acknowledgement. The local query store updates when the server broadcasts the accepted change back; there is no optimistic local insert to roll back. For workflows that must wait for the server to accept the write - so RBAC denials or schema validation errors surface before you navigate or trigger downstream work - use the `*Confirmed` variants: ```ts import { useMutations } from 'deepspace' const { createConfirmed } = useMutations<Item>('items') try { const recordId = await createConfirmed({ title: 'New', status: 'draft' }) navigate(`/items/${recordId}`) } catch (err) { // server rejected the write - show an error to the user } ``` `createConfirmed` returns the same client-generated `recordId` as `create`, but doesn't resolve until the server has acknowledged the write. ## Scopes A `RecordScope` is a single Durable Object that holds all the collections and records mounted inside it. The `roomId` is the DO's identifier - picking a different `roomId` gives you a separate DO with isolated data. ```tsx import { RecordProvider, RecordScope } from 'deepspace' import { SCOPE_ID } from './constants' import { schemas } from './schemas' <RecordProvider> <RecordScope roomId={SCOPE_ID} schemas={schemas}> <App /> </RecordScope> </RecordProvider> ``` `SCOPE_ID` from `src/constants.ts` is `` `app:${APP_ID}` `` - your app's main RecordRoom, keyed to the immutable `DEEPSPACE_APP_ID` rather than the app name, because names are mutable URL leases and ids never change (renaming the app keeps its data). Each scope is an independent DO with its own data. Nesting `<RecordScope>` lets you mount additional app-owned rooms - for example, one room per busy chat channel: ```tsx <RecordScope roomId={`chat:${channelId}`} schemas={messagingSchemas}> <ChatThread /> </RecordScope> ``` ## How writes flow When you call `useMutations.create(...)`, the SDK runs through five steps: 1. **WebSocket dispatch.** A typed message is sent to the `AppRecordRoom` Durable Object. 2. **RBAC check.** The DO checks the caller's role (established from their JWT at connect time) against the collection's `permissions`. 3. **SQLite write.** The DO persists the record in its local SQLite database. 4. **Broadcast.** The DO sends a `core.record_change` envelope (with `changeType: 'create' | 'update' | 'delete'`) to every connected client whose RBAC allows read access, including the sender. 5. **Apply.** Each subscribed client updates its query store and React re-renders. On the wire there is a single record-change message: `core.record_change`, carrying a `changeType` discriminator. The client store fans this out into internal `record_created` / `record_updated` / `record_removed` notifications for the React subscriptions - those names are SDK-internal and not part of the wire vocabulary. If the server rejects a plain write, query state never changes and `onWriteError` reports the rejection. A confirmed mutation rejects its promise as well. ## Beyond records DeepSpace records are tuned for **operational data** - collections of hundreds to tens of thousands of small rows, queried by client filters and updated frequently. For larger or analytical workloads: * **Files / blobs** → use [R2 file storage](/guides/file-uploads). * **Vector search** → declare a [custom Vectorize binding](/guides/custom-bindings) and call it from your worker. * **Analytics** → declare a custom Analytics Engine binding, or use the auto-provisioned `USAGE_EVENTS` dataset with [`meterUsage`](/sdk-reference/worker/bindings). * **External SQL** → declare a custom D1 database (with `runMigrations`) or a Hyperdrive binding to your own Postgres. ## Next steps * [Permissions](/concepts/permissions) - role-based access control on collections. * [Real-time sync](/concepts/realtime-sync) - the WebSocket protocol and consistency guarantees. * [Data storage guide](/guides/data-storage) - walkthrough for defining a schema and wiring up CRUD. * [Records reference](/sdk-reference/client/records) - full API for `useQuery` and `useMutations`. Source: /concepts/data-model.md --- # Permissions Role-based access control on collections, evaluated server-side in your Durable Object. DeepSpace enforces permissions in the Durable Object - the server checks every read and write before it broadcasts to clients. Permissions are declared per-collection in the schema. Client-side filtering is not a security boundary. The DO drops records the caller can't read **before** sending them over the WebSocket, so anything that arrives on the client is data the caller is allowed to see. ## Roles Every user has a role on each app's RecordRoom. The built-in roles are: | Role | Default for | Typical use | | -------- | ------------------------------------- | ------------------ | | `viewer` | Read-only users (or unauth, with `*`) | Public visitors | | `member` | All authenticated users | Normal app users | | `admin` | Explicitly promoted users | Owners, moderators | New authenticated users get `member` by default. Override that with `defaultRole` on the users schema. The only in-SDK way to promote a user is `useUsers().setRole(userId, 'admin')`, which the server rejects unless the caller is already an admin. The app owner is pinned to `admin` at connect time, so you don't promote them manually. Unauthenticated callers use the `'*'` wildcard key in the permissions block - there is no `anonymous` role identifier. ## The permissions block Every collection schema has a `permissions` object mapping role to operations: ```ts permissions: { '*': { read: 'published', create: false, update: false, delete: false }, viewer: { read: 'published', create: false, update: false, delete: false }, member: { read: true, create: true, update: 'own', delete: 'own' }, admin: { read: true, create: true, update: true, delete: true }, } ``` `read`, `update`, and `delete` each accept a `PermissionLevel` - the union of `boolean | 'own' | 'unclaimed-or-own' | 'collaborator' | 'team' | 'access' | 'published' | 'shared'`. `create` is the exception: it accepts a `boolean` only - you either let a role create rows or you don't. The levels you'll reach for most often: | Level | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------------- | | `true` / `false` | Allow all / deny all | | `'own'` | Caller is the owner. Default: `record.createdBy === userId`; override with [`ownerField`](#ownerfield). | | `'published'` | Owner OR matches `visibilityField` | | `'shared'` | Owner OR in `collaboratorsField` OR matches `visibilityField` | | `'team'` | Owner OR in `collaboratorsField` OR member of the team named in `teamField` | Less common: * `'unclaimed-or-own'` - requires `ownerField`; passes when that field is empty, or when the caller is the owner. Without `ownerField` set, behaves identically to `'own'`. * `'collaborator'` - owner OR in `collaboratorsField` (owners always pass) * `'access'` - equivalent to `'team'`; prefer `'team'` for clarity Import the type from `deepspace/schema` to typecheck against the full union: ```ts import type { PermissionLevel } from 'deepspace/schema' ``` ## `visibilityField` and `collaboratorsField` When you use `'published'` or `'shared'`, you tell the SDK which column to check: ```ts { name: 'posts', columns: [ { name: 'title', storage: 'text', interpretation: 'plain' }, { name: 'status', storage: 'text', interpretation: { kind: 'select', options: ['draft', 'published'] } }, { name: 'collaborators', storage: 'text', interpretation: { kind: 'json' } }, ], visibilityField: { field: 'status', value: 'published' }, collaboratorsField: 'collaborators', permissions: { member: { read: 'shared', create: true, update: 'own', delete: 'own' }, }, } ``` * `visibilityField` declares which column gates the `'published'` and `'shared'` rules. Use the string form (`'status'`) to match when `data.status === 'public'`, or the object form (`{ field, value }`) for any other sentinel value. * `collaboratorsField` declares which column holds the JSON array of collaborator userIds checked by `'shared'`, `'collaborator'`, and `'team'`. * `teamField` (used by `'team'` / `'access'`) declares which column holds the team ID. Membership is resolved against a `team_members` collection in the same scope, which must declare `teamId`, `userId`, and `status` columns - a row counts as a member when `status` is `'active'` or null. Without a `team_members` collection registered, `'team'` checks always fail. Define that collection in the app that owns the team data. ## `ownerField` By default, `'own'` checks against `record.createdBy` (set automatically when the record was created). To tie ownership to a different field (for example, an `assignedTo` user instead of the creator), set `ownerField`: ```ts { name: 'todos', columns: [...], ownerField: 'assignedTo', permissions: { member: { read: true, update: 'own', delete: 'own' }, }, } ``` Now `'own'` resolves against `record.data.assignedTo`. When you set `ownerField`, also mark the column `userBound: true` so a client can't claim someone else's id. The SDK's [schema-lint](/sdk-reference/worker/schemas#schema-lint-warnings) flags this (and other `visibilityField` / `userBound` foot-guns) at worker boot. ## `uniqueOn` - one-per-user rules Permissions decide *who* may write; [`uniqueOn`](/sdk-reference/worker/schemas#collectionschema) decides *how many times*. It declares a composite uniqueness constraint over a set of columns, and the Durable Object checks it on every create and update before the row is written - so "one vote per user per poll", "one RSVP per event", "one membership row per channel" hold against a forged or replayed client write, not just against a well-behaved UI. Pair it with `userBound: true` on the user column and the guarantee is complete: the client cannot choose whose row it is, and it cannot create a second one. ```ts { name: 'votes', columns: [ { name: 'pollId', storage: 'text', interpretation: 'plain', required: true }, { name: 'userId', storage: 'text', interpretation: 'plain', userBound: true, immutable: true }, { name: 'choice', storage: 'text', interpretation: 'plain', required: true }, ], uniqueOn: ['pollId', 'userId'], ownerField: 'userId', permissions: { member: { read: true, create: true, update: 'own', delete: 'own' }, }, } ``` A violation on a create or update is a **refusal on the write's ack**, not a thrown exception: the ack carries `success: false` with `Duplicate: a record with pollId=…, userId=… already exists in votes`. Handle it as the "already voted" branch. Updating `choice` succeeds because the unique tuple stays the same; an update that moves a row onto another row's tuple is refused. ## Worked examples ### A blog with public posts and member-only drafts ```ts permissions: { '*': { read: 'published', create: false, update: false, delete: false }, member: { read: true, create: true, update: 'own', delete: 'own' }, admin: { read: true, create: true, update: true, delete: true }, } ``` Visitors see only published posts. Members see their own drafts. Admins see everything. (The collection also needs `visibilityField` set - see above - for `'published'` to resolve.) ### A shared workspace with collaborators ```ts permissions: { member: { read: 'shared', create: true, update: 'shared', delete: 'own' }, admin: { read: true, create: true, update: true, delete: true }, } ``` Members read and edit anything they own or are listed as a collaborator on. Only the original owner can delete (or an admin). ### A private user-scoped collection ```ts permissions: { member: { read: 'own', create: true, update: 'own', delete: 'own' }, } ``` Each user sees only their own records. Useful for things like personal preferences, drafts, or AI chat history. ## Server-side enforcement The rules are enforced inside the Durable Object's `canRead()` / `canWrite()` checks. Three things follow from this: 1. **Permissions are checked before data ships over the wire.** A user without read access never receives the records - they're filtered out at the DO before the WebSocket broadcast. 2. **Client-side filtering is not enough.** Don't rely on the UI to hide records the user shouldn't see. A determined attacker reading WebSocket frames sees exactly what the DO sent. 3. **Bypassing RBAC requires a server action.** See [Server actions](/guides/server-actions) - they call privileged worker code with the `X-App-Action` header, which bypasses RBAC for orchestration that the user themselves couldn't perform. ## The users directory `useUsers()` is not a raw query over the `users` collection - it has its own privacy contract, enforced in the DO: 1. **Anonymous sockets receive no directory.** A public room must not turn the users table into a public directory, so unauthenticated connections get an empty list. 2. **Non-admins get the public-identity projection of every registered user.** Each row is cut down to `id`, `name`, `imageUrl`, `role`, and `lastSeenAt` - the fields collaborators need to name each other and to see who is around - regardless of the row-level `read` rule (`'own'`, `'team'`, ...). The row rule still governs full-row reads: `useQuery('users')` and the admin directory. Admins get the full row through their read policy - which is why `useUserLookup().getEmail` resolves only for admins. [`usePresence()`](/guides/presence-and-cursors) reads `lastSeenAt` straight off this roster, so online/offline works for every role, not only for admins. 3. **Two opt-outs live on the users schema.** A role whose `read` is `false` (or absent) receives an empty directory. `roster: 'read-policy'` scopes the directory to the rows the caller's `read` rule grants (still projected to public identity) - for apps that partition users by tenant or team and must not show names across the partition. Note that with it, every surface that names a peer through `useUsers()` (chat authors, assignees, presence) shows `Unknown` for users outside the caller's rows. The scaffold ships `member: { read: 'own' }` on the users schema, so a regular member's `useQuery('users')` is self-only while their `useUsers()` directory names everyone. Set `roster: 'read-policy'` deliberately when the product needs the directory partitioned too. `useUserLookup().getName(userId)` returns **`null`** for a peer the caller's roster does not contain - it resolves against the roster, and there is no server round-trip for a miss. The "Unknown" you see rendered next to an author or assignee is the app's own fallback for that `null`, not a value the SDK produced; choose whatever label fits your product. **Plain `useQuery('users')` bypasses the projection.** It is an ordinary collection read: every field the schema's read policy allows ships to the client, email included. Never set `member: { read: true }` on the users schema unless every current *and future* users column is intentionally member-visible - `read: 'own'` plus `useUsers()` for the directory is the safe default. ### The directory from the server side Two other paths read the `users` collection, and they do **not** share the client roster's rules: * **The `user.list` assistant tool** (the tools API surface an AI chat can call) answers with what its caller is allowed to see: an admin caller gets full rows, any other caller gets the same public-identity projection `useUsers()` returns. A tool that reads `users` is not automatically the roster - check what it projects, and to whom, before you expose it to a model. * **Server actions bypass the projection entirely.** Anything the worker calls with the `X-App-Action` header - [server actions](/guides/server-actions), cron jobs, chat-history helpers - reads **full rows**, email included, because the app's own server code is the trust boundary there. Never return a raw `users` row from an action to a caller without cutting it down yourself. ## Roles vs. visibility It's tempting to model "private messages" via `read: 'own'`, but `'own'` only matches a single user. Every participant in a DM needs read access, but only that exact set. The bundled messaging schemas therefore support public channels only; they do not pretend a client-side membership check is a security boundary. For private channels or DMs, define an app-specific schema with a participant field, declare it as `collaboratorsField`, and use `read: 'shared'` (or another server-enforced row policy). Keep the data in an app-owned room and test two authorized users plus one excluded user before calling it private. ## Debugging "why can't this user see X?" When a record isn't visible when you think it should be: 1. **Check the caller's role.** `useAuth().userId` gives you the user; `useUsers().users.find(u => u.id === userId)?.role` gives the role. 2. **Check the rule for that role and operation.** `read: 'published'` requires the record's `visibilityField` to match. 3. **Check the envelope.** `record.createdBy` is the `'own'` check; `record.data.<collaboratorsField>` is the collaborator check. 4. **Check the `*` rule.** If the user is signed out (anonymous), only `*` applies. Log the full record envelope locally to confirm the field values are what you expect. ## Next steps * [Schemas reference](/sdk-reference/worker/schemas) - full schema and permissions type signatures. * [Server actions](/guides/server-actions) - privileged writes that bypass user RBAC. Source: /concepts/permissions.md --- # Real-time sync How DeepSpace synchronizes data across clients using WebSockets and Durable Objects. DeepSpace syncs data between clients over a persistent WebSocket connected to a Durable Object. This page covers the wire protocol, how writes propagate, and the consistency guarantees the SDK provides. ## The model Every client holds a local replica of the records it has subscribed to. When any client mutates a record, the DO validates the write against [permissions](/concepts/permissions), persists it to SQLite, and broadcasts the update to every other connected client within milliseconds. Each [`RecordRoom`](/sdk-reference/worker/rooms#recordroom-e) is the single source of truth for its data. The DO owns its SQLite database and serializes all writes, so there are no merge conflicts to resolve. ## The mutation pipeline [`useMutations`](/sdk-reference/client/records#usemutations-t-collection) returns three mutation functions - `create`, `put`, `remove` - plus a `*Confirmed` variant of each. The plain functions are fire-and-forget over the WebSocket; the `*Confirmed` variants await a server ACK and reject on failure. ```tsx import { useMutations } from 'deepspace' const { create, put, remove, createConfirmed } = useMutations<Task>('tasks') // Fire-and-forget. Returns the new recordId immediately. // The local store updates when the server echoes the change back. const id = await create({ title: 'New task', completed: false }) // Awaits the server ACK. Rejects if RBAC denies the write. const id2 = await createConfirmed({ title: 'Important', completed: false }) ``` A call to `create`: 1. Generates a `recordId` client-side and sends a `core.put` message over the WebSocket 2. The DO verifies RBAC, writes to SQLite, and broadcasts `core.record_change` to every connected client whose RBAC allows it (including the sender) 3. The client store applies the change against any active [`useQuery`](/sdk-reference/client/records#usequery-t-collection-options) subscriptions and re-renders There's no local-optimistic apply step - the UI updates when the broadcast comes back. In practice the round-trip to a colocated Durable Object is single-digit milliseconds, so it feels instant. Use the `*Confirmed` variants when you need to know the server accepted the write - typically to surface a permission error inline, or to wait on a server-validated outcome before navigating. ## Subscription scope A client subscribes to one or more **scopes** (Durable Object instances). The default scope in the scaffold is `app:<APP_ID>` - keyed to the immutable app id, never the renameable app name, and exported as `SCOPE_ID` from `src/constants.ts` - every record in your app's main [`RecordRoom`](/sdk-reference/worker/rooms#recordroom-e) syncs to every connected client whose RBAC allows it. You can mount additional scopes by nesting [`<RecordScope>`](/sdk-reference/client/records#recordscope): ```tsx import { RecordScope } from 'deepspace' import { messagingSchemas } from './schemas/messaging-schema' <RecordScope roomId={`chat:${channelId}`} schemas={messagingSchemas}> <ChatThread /> </RecordScope> ``` Each scope is an independent DO with its own WebSocket. Subscriptions don't interfere with each other. ## What the DO sends When a client calls `useQuery`, the SDK sends a `core.subscribe` message. The DO replies with a `core.query_result` snapshot containing every record that matches the query and passes the caller's read check. From that point forward, the DO pushes incremental updates as `core.record_change` messages, each carrying a `changeType: 'create' | 'update' | 'delete'` discriminator alongside the record envelope. The client store applies each change against every active subscription. An update can move a record into or out of a query's `where` clause, so the same `changeType: 'update'` can mean different things to different subscriptions: | Wire message | Effect on a subscription | | ------------------------------------------------------------------- | --------------------------------------- | | `changeType: 'create'`, record matches `where` | Record is added to the result set | | `changeType: 'update'`, record matches and was already in the set | Record is updated in place | | `changeType: 'update'`, record now matches but wasn't in the set | Record is added (treated as a create) | | `changeType: 'update'`, record no longer matches and was in the set | Record is removed (treated as a delete) | | `changeType: 'delete'` | Record is removed if present | ## Consistency guarantees * **Writes are serialized inside the DO.** Two concurrent `put`s land in a deterministic order - the second wins on field-level merge. * **The DO sees all writes before any client.** There is no eventual consistency window from the DO's perspective. * **WebSocket disconnects trigger an automatic reconnect.** Active subscriptions re-subscribe and receive a fresh snapshot; the client store reconciles silently. * **In-flight `*Confirmed` calls reject if the socket drops** with `'WebSocket disconnected'`; calls made while already offline reject with `'WebSocket not connected'`. Fire-and-forget mutations sent while disconnected are silently dropped - use `createConfirmed` / `putConfirmed` / `removeConfirmed` when you need delivery guarantees. ## Permissions on the wire Permissions are enforced **before** the DO broadcasts. A user without read access to a record never sees it on the wire, so client-side filters are not a security boundary - they're a usability concern. If a schema declares a `visibilityField`, the DO re-evaluates read access on every update. When a record's value at that field transitions to the configured "visible" value (default: `'public'`), the DO broadcasts a `core.record_change` to clients that gain read access at that moment. The inverse also happens: making a record private produces a `changeType: 'update'` that the client store interprets as a delete for users who lose access. See [permissions](/concepts/permissions#visibilityfield-and-collaboratorsfield) for the full visibility model. ## Other room types The same WebSocket pattern applies to the other DO types exported from [`deepspace/worker`](/sdk-reference/worker/rooms), but the wire vocabulary differs: * [`YjsRoom`](/sdk-reference/worker/rooms#yjsroom-e) speaks the Yjs sync protocol. The DO holds the canonical Y.Doc and broadcasts updates. * [`CanvasRoom`](/sdk-reference/worker/rooms#canvasroom-e) stores shapes in a Y.Doc `Y.Map` and sends typed shape and viewport messages on top, optimized for high-frequency cursor and shape updates. * [`PresenceRoom`](/sdk-reference/worker/rooms#presenceroom-e) is fire-and-forget - peer state is held in memory only, never persisted. Updates broadcast at full speed. Use [`useQuery`](/sdk-reference/client/records#usequery-t-collection-options) / [`useMutations`](/sdk-reference/client/records#usemutations-t-collection) for durable, RBAC-filtered records. Use the room-specific hooks - [`useYjsText`](/sdk-reference/client/realtime#useyjstext-collection-recordid-fieldname), [`useCanvas`](/sdk-reference/client/realtime#usecanvas-roomid), and [`usePresenceRoom`](/sdk-reference/client/realtime#usepresenceroom-scopeid) - for CRDT text, canvas shapes, and presence. ## Disconnection and reconnection The SDK handles WebSocket lifecycle transparently: * On disconnect, the local store stays intact - UI keeps working with the last known snapshot. * The SDK reconnects with exponential backoff (capped at 30s) and on tab refocus. * On reconnect, every active subscription re-subscribes and receives a fresh `core.query_result` snapshot. * In-flight `*Confirmed` calls reject with `'WebSocket disconnected'`; calls made after the socket has closed reject with `'WebSocket not connected'`. Fire-and-forget calls made while offline are dropped - the SDK does not queue them. You can observe the connection state on each hook ([`useQuery`](/sdk-reference/client/records#usequery-t-collection-options)'s `status` returns `'loading' | 'ready' | 'error'`; [`usePresenceRoom`](/sdk-reference/client/realtime#usepresenceroom-scopeid) exposes a `connected` boolean). ## Next steps * [Data storage](/guides/data-storage) - define a schema and wire up CRUD. * [Presence and cursors](/guides/presence-and-cursors) - real-time presence with `usePresenceRoom`. * [Collaborative editing](/guides/collaborative-editing) - Yjs-backed text and shapes. * [Records reference](/sdk-reference/client/records) - the full hooks API. Source: /concepts/realtime-sync.md --- # Deployment How DeepSpace deploys your app to Cloudflare Workers for Platforms. `npx deepspace deploy` builds your app and uploads it to Cloudflare Workers for Platforms, returning a live `<name>.app.space` URL. ## What `deploy` does The CLI builds your app with Vite (the Cloudflare plugin produces both the client bundle and the worker), validates your binding manifest, and uploads everything to the deploy worker as one request. From there, the platform auto-provisions any binding marked `"auto"`, binds every value in the selected [secrets config](/guides/secrets) as a `secret_text` binding, and registers the worker in the dispatch namespace at `<name>.app.space`. Deploys are idempotent in the sense that matters: re-running `deploy` with no source changes reuses provisioned resources and re-uploads nothing — unchanged assets are already in the content-addressed store. It is **not** a no-op, though. Every deploy appends a release fact, so a byte-identical redeploy still advances the release history and is still something you can roll back to. See [Releases and rollback](/guides/releases-and-rollback). See [Build & deploy pipeline](/concepts/architecture#build-deploy-pipeline) for the full step-by-step. Inspect deployed apps, logs, and traffic in the web dashboard at [dashboard.deep.space](https://dashboard.deep.space). ## App identity `DEEPSPACE_APP_ID` in `wrangler.toml` is the app's immutable identity — data, secrets, collaborators, billing, and custom domains all key to it. The `name` field is only a lease on `<name>.app.space`. A repo without an id needs no setup step: the **first deploy mints one** and writes it into `wrangler.toml`. **Commit that change.** The id is the app's permanent identity, not a secret, and a checkout without it is a different app waiting to be minted. To stamp the id without deploying, run `npx deepspace app init`; to fork a cloned repo into a separate app with its own data and secrets, run `npx deepspace app init --new-id`. ## Subdomains `<name>.app.space` is a fully production-grade URL with SSL - most apps ship and stay on it. The `name` field in `wrangler.toml` is your subdomain: ```toml name = "my-app" # → deploys to https://my-app.app.space ``` Rules: * Lowercase alphanumeric with optional dashes, 2-63 characters; cannot begin with a dash * Non-conforming names cause the CLI to fail with a clear error and the suggested canonical form; fix `name` in `wrangler.toml` and re-run * **Names are globally unique.** One already claimed by another app is refused with `The name <host> is taken by another app.` * Changing `name` and re-deploying **renames the app** rather than creating a second one. The URL moves; data, secrets, and collaborators travel with it, because they key to the immutable `DEEPSPACE_APP_ID` rather than to the name. The CLI prompts to confirm, or pass `--rename` to confirm non-interactively. * After a rename, the old subdomain stays **reserved for its previous owner for 30 days**. You (or the same app) can reclaim it at any point in that window; only after it expires can another account take the name. If you'd rather use your own domain (e.g. `myapp.com`), see [Custom domains](/guides/custom-domains). ## State preservation A deploy **does not** wipe Durable Object state. Your records, conversations, files, and cron history persist across deploys. **Survives a deploy:** * Durable Object SQLite tables (records, Yjs documents, canvas state, cron history) * R2 bucket contents * Auto-provisioned bindings (D1, KV, Vectorize, etc.). Resource IDs are persisted and reused. * The app's [secrets store](/guides/secrets). Every deploy re-binds its values as `secret_text` bindings. **Does not survive a deploy:** * Worker module-level globals * In-memory caches inside DOs. Every DO restarts; persistent storage stays, in-memory state is rebuilt on the next request. ### Schema migrations The SDK runs **additive** schema migrations automatically. When a DO cold-starts after a deploy, the per-collection migrator creates any new tables and runs `ALTER TABLE ADD COLUMN` for new fields. Re-running with an unchanged schema is a no-op. **Destructive** changes are not handled: dropping a column, changing a column type, renaming a field, or backfilling data into a new shape. You're responsible for any data migration before deploying a breaking schema change. ## Secrets come from the store, not from `.dev.vars` Every app has one platform-owned, encrypted [secrets store](/guides/secrets), keyed by `DEEPSPACE_APP_ID`. That store is the **only** secrets input to a deploy: `deploy` pulls the selected config, binds each value as a Cloudflare `secret_text` binding, and reconciles the worker's bindings against the store — a key deleted from the store disappears from the live worker on the next deploy. Worker code reads `env.<NAME>` identically in dev and production, and values never appear in compiled assets or client bundles. `.dev.vars` is a generated plaintext cache of that store, rewritten **whole** by the CLI — hand edits vanish, deploy never reads it back, the scaffold's `.gitignore` keeps it uncommitted, and store changes reach a deployed app on the next `deploy` and a running dev session on restart ([cache behavior](/guides/secrets#cache-behavior)). A missing secrets config (`prd`, or `<name>` for `[env.<name>]`) refuses deploy with `secrets_config_missing` and an executable fix, while an explicitly created **empty** one deploys and removes every user-secret binding — that distinction, plus name rules and caps, lives in the secrets guide's [Missing is not empty](/guides/secrets#missing-is-not-empty) and [Names and caps](/guides/secrets#names-and-caps). ### `APP_IDENTITY_TOKEN` One generated key deserves a note. `APP_IDENTITY_TOKEN` authenticates app-origin platform calls — payments, files, and screenshot APIs — and appears in `.dev.vars` only once the app is registered with the platform. Deploy registers it, and an earlier secrets write registers it too (the first write claims the app id), so it can exist before the first deploy. Until one of those happens, those local APIs run without app-origin authentication — if a payments or files call fails on a brand-new app, register the app first. ## Named environments A `[env.<name>]` block in `wrangler.toml` is not a variant of your app — it is a **separate app**: its own canonical `name`, its own `DEEPSPACE_APP_ID`, its own Durable Objects, and its own secrets config. Initialize it explicitly, or let its first deploy mint the id: ```bash npx deepspace app init --env staging # stamp [env.staging] with its own id npx deepspace deploy --env staging # deploys <staging-name>.app.space with secrets config "staging" ``` Commit the minted id, same as the top-level one. Remove the environment with `npx deepspace app undeploy --env staging` when it has served its purpose. ### Wrangler environments do not inherit Wrangler named environments do **not** inherit `vars`, Durable Object bindings and migrations, assets, or KV/R2/D1 declarations from the top level. Repeat every required block under `[env.<name>]`, or the deployed worker boots without them: ```toml name = "my-app" [vars] DEEPSPACE_APP_ID = "app_..." [env.staging] name = "my-app-staging" [env.staging.vars] DEEPSPACE_APP_ID = "app_..." # the staging app's own id # Repeat the durable_objects bindings, migrations, assets, and any # KV/R2/D1 blocks the top level declares, under [env.staging.*]. ``` ### The browser bundle needs the environment's app id Server code gets the right id per environment through `env.DEEPSPACE_APP_ID`. The browser bundle does not — if `src/constants.ts` hardcodes the production id, a staging build connects the browser to **production** rooms while staging server actions write to staging rooms. The current scaffold injects the active id at build time, from the wrangler config the build targets: ```ts // src/constants.ts declare const __DEEPSPACE_APP_ID__: string export const APP_ID: string = __DEEPSPACE_APP_ID__ export const SCOPE_ID = `app:${APP_ID}` ``` ```ts // vite.config.ts import { deepspaceBuild } from 'deepspace/build' // … plugins: [cloudflare(), deepspaceBuild({ appDir }), /* … */] ``` ```ts // vitest.config.ts import { appIdDefine } from 'deepspace/build' export default defineConfig({ define: appIdDefine({ appDir }), /* … */ }) ``` Three files, one change - `src/constants.ts` on its own builds clean and then throws `ReferenceError` in the browser. `deploy` refuses a bundle that carries another app's id (`app_id_env_mismatch`) or the unreplaced define (`app_id_define_unsubstituted`), and the refusal prints this retrofit; `app update` reports the same retrofit for an older app as the `2026-08-build-injected-app-id` migration guide - see [Updating an app](/guides/updating). Gate staging-only routes on an explicit staging signal (an env-derived flag), never on "not production" heuristics. ## When is a deploy actually live? `deploy` does not return until it has checked, and it tells you what it established rather than assuming: | `serving` | Means | | -------------- | ------------------------------------------------------------------------------------------- | | `confirmed` | Ten consecutive fresh connections all answered with this release | | `unconfirmed` | Some requests still get the previous release — it is rolling out | | `unverifiable` | This release carries no stamp (older platform, or a resumed deploy), so the CLI cannot tell | The mechanism: every release ships a stamp at `/.well-known/deepspace/release.json` carrying its own identity, served `no-store`. After uploading, the CLI polls that path and requires several agreeing answers **over separate connections**. **Separate connections is the whole point.** Cloudflare rolls a new version out per edge machine. Within one keep-alive connection every response agrees, so a naive poll reusing a socket converges instantly while a good fraction of the fleet is still serving the previous release. Reusing one connection is how a deploy can report success and still hand a browser stale code. `confirmed` means confirmed **from where the CLI is standing**. Other regions may still be rolling over — per-colo propagation is not something a client can force, and this is best-effort rather than a guarantee. If you are asserting against a fresh deploy in CI, treat `unconfirmed` as "wait and retry", not as a failure. This is also why a browser tab open across a deploy can ask for a hashed asset that no longer exists. Missing assets answer `404` rather than the app shell, and the scaffold reloads once on Vite's `vite:preloadError` so a lazily-loaded route recovers instead of silently dying. ## Custom bindings Add Vectorize, Workers AI, R2, KV, D1, Queues, Browser Rendering, Hyperdrive, or Analytics Engine bindings by declaring them in `wrangler.toml`. Set the ID to `"auto"` to auto-provision: ```toml [[vectorize]] binding = "VEC" index_name = "auto" dimensions = 768 metric = "cosine" [[d1_databases]] binding = "MY_DB" database_id = "auto" database_name = "my-app-db" ``` The deploy worker provisions the resource the first time you deploy, persists the ID, and reuses it on every subsequent deploy. See [Custom bindings](/guides/custom-bindings) for the full list of types and gotchas. ## Undeploy To take an app down: ```bash npx deepspace app undeploy ``` This removes the subdomain registration, tears down auto-provisioned resources (with one exception), and cleans up bindings. The exception: * **R2 buckets with files in them are not auto-deleted.** This protects against accidental user-content loss. Empty the bucket manually if you want it gone. Durable Object data **is** removed when the worker is undeployed. Back up anything you want to keep. Undeploy is not deletion of the app itself: the app id, its registration, collaborators, and its [secrets store](/guides/secrets) remain, so a later deploy of the same id revives the same app. ## Before your first deploy Three things to verify before shipping (the [product-polish checklist](/design/product-polish#verify-with-a-smoke-test) covers the UI side): * **Settle the app name.** Renaming later is safe — data, secrets, and collaborators follow the app id ([subdomain rules](#subdomains)) — but every rename moves your public URL. * **Audit the secrets store.** `npx deepspace secrets list` shows exactly what ships as `secret_text` bindings. Confirm production credentials, not test keys. * **Run `npx deepspace test run`.** Playwright runs your specs against a local build before you deploy them against the live one. ## Next steps * [Deploy command reference](/cli-reference/commands#deploy) - flags, environment variables, and edge cases. * [Secrets](/guides/secrets) - the store model, configs, caps, and troubleshooting. * [Releases and rollback](/guides/releases-and-rollback) - the release ledger and the refusal taxonomy. * [Custom bindings](/guides/custom-bindings) - declare Vectorize, R2, D1, and more. * [Custom domains](/guides/custom-domains) - optionally buy and attach your own domain from the CLI. * [Testing](/guides/testing) - run Playwright specs before you ship. Source: /concepts/deployment.md --- # Building an app The end-to-end methodology for building a whole product on DeepSpace - research, spec, design, de-risk, plan, build, verify, ship. This is the methodology for building a **complete app end to end** - a new product, a clone of an existing product, or any multi-feature build. It is written for coding agents and for humans running long builds alike; the rules are the same either way. Skip it for single-feature additions and bug fixes. The core law: **research and de-risking come before building; verification comes before "done."** The order below is load-bearing - phases overlap and you will loop back, but never skip forward. Account setup, scaffolding, and the feature catalog are covered in [installation](/get-started/installation) and the [quickstart](/get-started/quickstart) (scaffold whenever you need a repo to work in; research needs no code) - this page sequences the work around them. ## 1. Research before any code If the app follows a reference product (a clone, "like X but…", a screenshot), study the real thing first - never build "like X" from memory. * **Reverse-engineer it end to end**: the full feature surface, the core loop, pricing and tiers, the data model - and the real mechanics underneath. Drive the live product headlessly (Playwright, or capture with `npx deepspace test screenshot <url> <out.png>`) and **watch the network traffic while you drive** - the API calls and request/response shapes reveal how it actually works: the pipeline, the providers, sometimes the prompts. Get to the secret sauce, or say plainly what stayed a black box - never guess it. The public web and any materials the user gave you fill the rest. * **Ask for access to gated surfaces.** If key surfaces sit behind a login, ask the user for access or screenshots - don't create accounts on someone else's product without their say-so. * **Save a reference corpus** - screenshots of every surface, notes, captures - in a stable folder (`docs/refs/` or similar). Every later phase diffs against this corpus; if you don't have enough screenshots, go take more. * **Write findings into a small docs wiki.** Note where each claim came from, and mark inferences as hypotheses, never as facts. * **Check what the platform already gives you first.** Walk the feature installer (`npx deepspace add`) and the [integration catalog](/guides/external-apis) (`npx deepspace integrations list`) so you don't hand-build what exists. Nothing outside the catalogs is a blocker - any external API works with the user's own key (`npx deepspace secrets set KEY=...`). Classify each capability: SDK primitive, catalog integration, or wire-it-yourself - the wire-it-yourself ones are your prime de-risking targets (step 4). For a from-scratch product, the same step applies to the *domain*: study two or three real products in the space before inventing features. **A feature invented from guesswork ("users probably want a live feed") gets thrown away - ground it or cut it.** ## 2. Decide everything before code Drive the spec until it passes the **zero-questions test**: a designer and a build agent could execute it with zero clarifying questions back. A feature left as a noun ("analytics", "AI assistant") is a hole - resolve its input, source of truth, edit path, and empty, loading, and failure states. Spec the **whole product** the research describes - don't quietly plan an MVP slice; staging is the user's call, not a default. The counterweight is the **realism gate**: for each feature, ask who clicks it and why - cut what only sounds good in theory, and say what you cut. Surface the genuinely ambiguous product decisions to the user *now*, **in one batch with a recommendation on each** - not one at a time, and not mid-build. If the user is away, write the batch down, adopt your recommendations, and keep moving. Record decisions in a decisions file; once locked, don't relitigate without new evidence. ## 3. Pin the design source Every build has exactly one design source of truth, chosen up front. Which path is the user's call - if they haven't said, ask, folded into the step-2 question batch: * **A design prototype** from a design-generation tool. When handing off to one, give it the *complete* product spec but **no layout prescription of your own**: features and specs in full, no pages, screens, or navigation. UI requirements the user stated are the exception - pass those through. Prescribing layout produces rigid, generic design; withholding product info produces wrong design. Never feed the tool the reference product's own screenshots or brand - that only makes it copy them. Generation tools do well on conventional product and marketing layouts and badly on unusual shells (multi-pane workspaces) - prefer one of the two paths below for those. * **The reference product's screenshots** - for exact-parity clones. Treat the corpus as the bible; parity means every button, section, and option, not the general vibe. Parity covers structure and features, never identity: don't ship the original's name, logo, brand assets, or copy. * **Self-design against studied references** - screenshot two or three genuinely good real products first (ones the user names, or find your own), and note which section or component pattern you're borrowing from which product. Those screenshots join the reference corpus: design against them and keep diffing against them through the build. Studying a reference once and then recreating UI from memory produces the same slop as never studying it. A vetted design is **copied, not "improved"** - idle edits are how good designs degrade. Behavior a static frame can't show (live data, motion) is yours to design deliberately. When self-designing, follow the [design workflow](/design/overview) - direction, style tile, patterns, and the anti-AI gate. ## 4. De-risk the load-bearing bets For anything the app stands on that you haven't proven - a risky integration, the core generation or data pipeline, a cost assumption, a quality bar - run a small, timeboxed spike **before building the feature on top of it**. Prove the recipe with real calls and real output: ```bash npx deepspace integrations invoke <provider>/<endpoint> --body '{...}' # real call, real output, real cost ``` Let the result decide the design. `list` and `info` discovery is free; `invoke` is billed - stay inside any budget the user granted (see [external APIs](/guides/external-apis)). Experiments are cheap; rebuilding a wrong foundation is not. If a load-bearing bet fails and there's no alternative, that is a stop-and-ask moment - never silently descope the core feature or ship a degraded stand-in. ## 5. Plan top-down, then build in phases Design the whole system first - architecture, module boundaries, the data model (collections plus RBAC), the SDK surfaces each subsystem uses, folder structure, and conventions (naming, where logic lives, error handling) - then recurse into each part until nothing load-bearing is vague. The depth test: **a fresh agent could implement each part from its doc alone, with zero questions back.** The plan docs are the build contract. Scale planning to the build: recursion stops when ambiguity is gone, not at a page count - never let the plan grow heavier than the code it guides. ### Build order and parallel work Build the **shared foundation first** - schemas, RBAC, worker routes, theme tokens - through **one writer**. Two hands making different implicit decisions in the foundation silently corrupt everything built on it. Features come after, on top. Building on a single thread is the default. Parallelize across sub-agents only when the work splits cleanly, and then under five conditions: 1. **Exclusive file ownership per stream** - no two agents ever touch the same file, or the same decision. 2. **Conventions inlined in every brief** - never just "see the plan". 3. **The absolute working directory pinned in every brief.** 4. **Cross-stream changes routed through you** - never agent-to-agent edits. 5. **Spike any shared recipe on one target before fanning it out** - a broken shared instruction multiplies across every parallel worker. Reading work - research, experiments, reviews - parallelizes freely; it's parallel *writing* that needs these rules. ### One isolated checkout per line of work Each stream gets a durable, resumable branch and an isolated checkout: a DeepSpace workspace (`npx deepspace workspace new -t "<what this is for>"`) or, under GitHub source, an ordinary Git branch and worktree - which applies is the app's [source mode](/guides/source-control) — `npx deepspace app source --json` for a claimed app, or `npx deepspace status --json`'s `sourceInference` for an unclaimed one. DeepSpace `workspace sync` reports path overlap with live peers, but the warning is advisory; keep exclusive file ownership in the briefs regardless. Commit and publish through the selected source's normal flow as you go. Parallel checkouts each get their own dev server on a deterministic port - see [dev workflow](/guides/dev-workflow). ## 6. Verify like a user - green gates are a false green `tsc` clean and passing tests say nothing about whether the app works or looks right - the classic failure is every automated gate green and the live app broken on the first click. Before calling anything done, run all six gates: Verify dynamic product surfaces with real persisted state, not mock rows that merely make a screenshot look populated. Authored marketing copy and clearly labeled sample/demo content on a static signed-out landing page are fine; they are presentation, not a substitute for exercising the app's data path. 1. **Type-check and tests** (`npx deepspace test run`) - necessary, never sufficient. 2. **Inspect the visual surfaces that changed.** Use targeted screenshots when layout or design parity matters; assertions remain the primary verification. 3. **Diff against the design source** - side by side with the reference corpus or prototype, surface by surface. 4. **Live-smoke the core loop on the deployed app** - drive it headlessly against the deploy, signed in as a fresh test account, and do the thing the app exists for, as a user would. 5. **Multi-user features get multiple real sessions** (`npx deepspace test accounts list`, and a two-user spec - see [testing](/guides/testing)) - never verify collaboration with a single tab. 6. **Exercise the failure states** - denied, expired, empty, offline. A raw exception reaching a user is a defect. Report with evidence - the screenshot, the output, the live URL - and keep a hard line between **built-and-verified**, **built-but-unverified**, and **not built**. Never blur them. Assume the first pass is wrong and budget for verify-fix cycles; close the loop on a check that produces a real pass or fail, not on "looks done." **Checks must measure intent, not a proxy.** A metric an agent can satisfy without satisfying the user (a motion score, a parity percentage you graded yourself) will get gamed. When the bar is taste, produce a side-by-side comparison and let the user judge. ## 7. Review as you build, walk the spec, ship * **Review periodically, not only at the end** - after each major feature or big chunk of work: review the new code, and step back to the system level (is a mess accumulating? is a refactor or simplification due?). Catching drift mid-build is cheap; untangling it at the end is not. * **Independent review** by a fresh context that didn't write the code - mandatory for money, auth and permissions, anything that fans out to users, and anything hard to reverse. Brief reviewers to flag only what genuinely affects correctness or the stated requirements - a reviewer sent to "find problems" invents them. No way to spawn a fresh context? Degrade honestly: re-review against the spec with deliberately fresh eyes, and say the review wasn't independent. * **Adjudicate findings yourself.** Review agents exaggerate: verify each finding against the actual source, discard what you can't ground, and reject over-engineered fixes - keep the real issue, apply the simplest correct change. Loop review, fix, re-review until a pass finds nothing of value. * **Money paths get the hardest review**, and their invariants get pinned with a test: amounts resolved server-side, never client-trusted; entitlements checked on every gated request; failures fail closed. **Never hand-roll Stripe** - see [payments](/guides/payments). * **Walk the spec feature by feature** against the research and decisions docs and confirm each feature is built and verified. This completeness walk - not your own sense of progress - decides "done." A self-graded "mostly there" is not a completeness check. * **When everything looks 100% done, run a whole-system design review** - a high-level pass over the finished codebase: refactor where it simplifies, remove dead code, split what grew too big, make it leaner and more robust. This is what keeps the codebase maintainable. Then re-run the step-6 gates - a refactor isn't done until verification confirms nothing broke. * **Every cut or deferral is communicated with a reason** - never a stubbed "coming soon", a silently skipped hard part, or a substituted reference. If a named resource is missing, ask; never quietly swap. ### Landing and deploying * **Land before you deploy** when DeepSpace-source workspace work is meant for trunk - an intentional workspace deploy must first sync its exact HEAD. Deploy semantics per source mode (commit-first vs ships-the-working-tree) are [source control](/guides/source-control)'s contract. Every deploy appends a release; [rollback](/cli-reference/commands#rollback) works only while its bundle is retained. See [deployment](/concepts/deployment). * **Pre-launch, with no users: deploy autonomously** on green gates plus a live smoke. Rehearse risky changes with `deploy --env staging`. * Then hand the user the live URL with a short what-to-test-first list. **The user driving the live product is the final gate** - taste doesn't automate. Park any taste calls you couldn't settle for that moment. ## Long builds: state lives on disk, not in context Context gets compacted; anything not written down gets forgotten or relitigated. On resume, run `npx deepspace status` for present facts, read `npx deepspace activity` from the cursor you retained, and re-open the state files below. * **A task list** - the running to-do. While it's non-empty there is always a next action; this is what prevents stopping halfway. * **A state and decisions file** - current phase, exact next step, locked decisions. The first thing to re-read on resume. * **A lessons file** - append every gotcha or wrong assumption the moment you learn it, and carry it into the next task or sub-agent brief so no mistake repeats within the build. * **Commits, not memory, are the undo.** Commit before every risky pass (reviews, refactors, redesigns) so there's something to go back to. Publish through `deepspace push` or `workspace sync` for DeepSpace source, or ordinary Git for GitHub source, so the work survives this machine. Commit code; keep planning and coordination docs out of the repo (gitignore the docs folder) and out of any published repo. ## Decide vs ask The solo-project default: **decide and keep moving.** Make the small calls (copy, naming, layout details, component choices) and log them. Ask only for what's irreversible or outward-facing: * Spending real money beyond an explicit budget. * Logins or OAuth flows only the user can complete. * Buying a domain. * Publishing or announcing to real users. * Brand or positioning stakes. * A load-bearing bet that failed de-risking. Don't pause a build to check in - a working product is the floor. And know when to stop: a fix that has failed several times in a row means come back with what you tried and what you observed, not another lap of the same loop. When you report, lead with the result - point-wise, in plain terms, with zero assumed context - and be dead honest about what's verified versus not. ## Where builds go wrong Recurring failure modes and the rule that prevents each. (The review and deferral reflexes live in step 7.) | Failure | Rule | | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | Declaring done on green gates while the live app is broken | Done = the core loop driven on the deploy, as a fresh user, with evidence | | Stopping with features unbuilt, or after one dead end | Keep a task-list file; keep going until the walk-the-spec check passes; route around blockers | | Grading your own parity ("mostly matches") | Diff item by item against the pinned reference corpus, never memory | | Ignoring or losing an explicit instruction (especially across compaction) | Pin user-stated requirements in the spec and decisions file; re-verify against them before done | | Inventing features or facts by guesswork | Ground load-bearing choices in real calls (`invoke`), real docs, real data - or ask | | Fake or synthetic data anywhere (UI stats, demos, fixtures) | Real data or clearly labeled placeholder; demos generated by the real pipeline | | Patching symptoms; deleting a feature to kill its bug | Root-cause first; fix at the layer that generates the bad data | | Fixing only the reported instance | Treat each report as a class; sweep the whole surface for its siblings | | Shipping scaffold chrome or template-shaped UI | The scaffold is a placeholder - design against studied real references ([product polish](/design/product-polish)) | | Test data leaking into the live app | Clean up seeded data in `finally`; audit for orphans before handoff | | "Improving" a proven design or pipeline while replicating it | Replicate exactly; diverge only where the user granted latitude | | Wrong or stale working directory (sibling checkouts exist) | Pin the absolute cwd everywhere; verify the folder is the canonical, synced one | | Hours of work living only in an uncommitted worktree | Commit as you go and publish through the app's selected source; a GitHub deploy may ship dirty bytes, but that is not durable source | | Two streams silently editing the same files | One isolated worktree per line of work; for DeepSpace workspaces, read the advisory overlap report before landing | ## Next steps * [Dev workflow](/guides/dev-workflow) - the local runtime and per-worktree ports for parallel checkouts. * [Testing](/guides/testing) - the suites and the multi-user fixture behind the verification gates. * [External APIs](/guides/external-apis) - the catalog, real-call spikes, and billing modes. * [Deployment](/concepts/deployment) - what a deploy ships, releases, and rollback. Source: /guides/building-an-app.md --- # Dev workflow The one local runtime, deterministic worktree ports, the Claude desktop preview adapter, and how to diagnose a stale preview. A DeepSpace app has exactly one supported local runtime: ```bash npx deepspace dev start ``` It runs Vite and the worker together, regenerates `.dev.vars`, and binds one port. There is deliberately no second `vite preview` script in the scaffold - a preview server that bypasses the CLI would run without the SDK-managed secrets and platform wiring, so the scaffold does not offer one. Everything on this page is about making that one runtime behave predictably when you run several checkouts of the same app side by side. ## Port resolution `dev start` picks its port in this order: 1. An explicit `--port` flag. 2. `$DEEPSPACE_PORT` in the environment. 3. A stable per-worktree port, when the checkout is a linked Git worktree (below). 4. The default, `5173`. `npx deepspace test run` and `npx deepspace dev kill` resolve the port the same way, so all three commands target the same server by default. That symmetry is load-bearing: a test run pointed at a different port silently exercises whatever server happens to be listening there - usually the primary checkout's stale code - via Playwright's server reuse, and passes for the wrong reason. ## Ports in linked worktrees When you work on the same app in parallel - one checkout per line of work - each checkout needs its own dev server, and the servers must never collide. DeepSpace detects linked checkouts from **Git metadata, not directory names**. Any registered linked worktree - created by Claude, Codex, or plain `git worktree add` - gets a deterministic port in the **5180-6179** band, derived by hashing its canonical checkout path. The primary checkout keeps the normal `5173` default. The derived port is stable across runs for the same worktree path, so a worktree's server always lands in the same place without any configuration. If two worktrees happen to hash to the same port, the launch-config writer probes past ports already claimed by other entries. An explicit `--port` or `$DEEPSPACE_PORT` always wins over the derived port. ## The Claude desktop preview adapter Claude Code's desktop preview reads `.claude/launch.json` to know how to start your app. A normal app entry runs the current command tree: ```json { "name": "<app>", "runtimeExecutable": "npx", "runtimeArgs": ["deepspace", "dev", "start", "--port", "5173"], "port": 5173 } ``` `dev start` seeds this entry on first run and keeps its `port` in sync when you pass an explicit `--port`. `.claude/launch.json` is **machine-local**. Keep it and `.claude/worktrees` gitignored (the scaffold already does), and never commit absolute worktree paths. ### Worktrees and the owning checkout The desktop preview tool reads only the **owning checkout's** launch file - a worktree's own `.claude/launch.json` is never read. DeepSpace treats a checkout at `<owner>/.claude/worktrees/<name>` as this adapter case only when Git reports both the owner and the child as registered checkouts of the same repository; a matching path string alone has no effect, so an ordinary directory that merely looks like a Claude worktree can never mutate an unrelated ancestor's launch configuration. From inside the worktree, run once: ```bash npx deepspace dev start ``` The CLI then: 1. Upserts a `wt-<name>` entry into the **owner's** `.claude/launch.json`, pinned to the worktree's exact `cwd` and its resolved port. 2. Prints the entry name. 3. Prunes only stale `wt-*` entries - ones whose absolute `cwd` sits under that owner's `.claude/worktrees` directory and no longer exists. Every other entry, including a hand-authored `wt-*` entry pointing elsewhere, is preserved verbatim. Start the desktop preview with the printed `wt-<name>` entry. Codex and ordinary Git worktrees need no worktree-specific adapter - the derived port alone keeps them isolated - though a scaffold may still ship the machine-local launch file for Claude interoperability. ## Diagnosing a stale preview If the preview shows code you already changed, the usual cause is a server running from the wrong checkout. Diagnose it in order: 1. **Check the server's `cwd` and port** in the preview's server listing. A server whose `cwd` is the primary checkout while you edit in a worktree is the whole bug. 2. **Prove it with a distinctive string.** Add a unique marker string to a source file in your checkout and request that source through the dev server. If the marker is absent from the response, the wrong checkout is being served - no further guessing needed. 3. **Stop the mismatched server, then start the right entry** - the printed `wt-<name>` entry for a worktree, the app entry for the primary checkout. Never kill an unrelated process just because it holds the port you expected. Identify the server first (step 1); if the process on the port is not your dev server, pick a different port with `--port` instead. ## Build output and the `.dev.vars` copy Cloudflare's build can emit a preview-only secrets copy at `dist/<worker>/.dev.vars` beside the generated worker bundle. Because `dev start` is the only local runtime, that second copy has no consumer - so the `deepspaceBuild()` plugin in the scaffold's `vite.config.ts` removes it after every build (the same plugin supplies the build-time app id), and `deploy` deletes it again before collecting artifacts. The root `.dev.vars` remains the single local materialization of your secrets. If your app predates `deepspaceBuild()`, run: ```bash npx deepspace@latest app update ``` The read-only guide reports the `2026-08-build-injected-app-id` migration and names all three files to update. Apply and validate that guidance before recording its id in `deepspace.migrations.json` or relying on a generic `vite build` output as an archive or artifact - see [Updating an app](/guides/updating). ## Next steps * [Building an app](/guides/building-an-app) - the end-to-end methodology, including one isolated checkout per line of work. * [Testing](/guides/testing) - `test run` suites, ports, and the multi-user fixture. * [Deployment](/concepts/deployment) - what `deploy` ships and how secrets reach production. Source: /guides/dev-workflow.md --- # Authentication Public, gated, and mixed auth configurations for DeepSpace apps. Authentication runs on the platform's [auth worker](/concepts/architecture#talking-to-platform-workers), so you don't run an OAuth flow, mint JWTs, or manage sessions. The SDK ships React providers and components that wrap [Better Auth](https://www.better-auth.com/), plus a [`verifyJwt`](/sdk-reference/worker/auth#verifyjwt-config-token) helper for your worker. ## Auth models DeepSpace apps usually fall into one of three shapes. The scaffold ships the mixed model - `src/pages/(app)/(protected)/_layout.tsx` applies [`<AuthGate>`](/sdk-reference/client/auth#authgate-fallback-redirectonsignout) to everything inside it. Mixed (default) The scaffold uses two nested route groups. `(app)/` supplies the auth and realtime providers; `(protected)/` inside it additionally requires sign-in. ``` src/pages/ index.tsx static landing (/) - no providers (app)/ _layout.tsx DeepSpaceAuthProvider + RecordProvider home.tsx dynamic, public (/home) (protected)/ _layout.tsx <AuthGate><Outlet /></AuthGate> settings.tsx gated (/settings) // add gated pages here ``` Folders in literal parentheses are generouted route groups - they apply a layout without appearing in the URL. Adding a gated page is a one-file change: drop it inside `src/pages/(app)/(protected)/`. **Put gated pages in the existing group - don't create a new one.** `(protected)` is not a name the router understands; it gates its children purely because that specific folder contains a `_layout.tsx` mounting `<AuthGate>`. A page you place at `src/pages/(protected)/foo.tsx` (top level, outside `(app)/`) forms a *different* group with no layout at all. It is **publicly reachable**, and because it also sits outside `(app)/_layout.tsx` it has no `<RecordProvider>`, so `useQuery` / `useMutations` fail there too. **Best for:** consumer apps with a public landing or marketing surface plus an authenticated app behind sign-in. Fully gated Two edits to the scaffold's `src/pages/(app)/_layout.tsx`: wrap [`AuthBoot`](#the-authboot-helper) with [`<AuthGate>`](/sdk-reference/client/auth#authgate-fallback-redirectonsignout), and drop `allowAnonymous` from the [`<RecordProvider>`](/sdk-reference/client/records#recordprovider) inside `AuthBoot` - inside the gate the client is always signed in, so the anonymous WebSocket path is dead code: ```tsx // src/pages/(app)/_layout.tsx export default function AppLayout() { return ( <DeepSpaceAuthProvider> <AuthGate> <AuthBoot> <Navigation /> <main><Outlet /></main> </AuthBoot> </AuthGate> </DeepSpaceAuthProvider> ) } ``` Pages left at the top level of `src/pages/` stay static and ungated - move them under `(app)/` if every route must require sign-in. **Gate the server too.** `<AuthGate>` gates the UI and the SDK client, not the Worker: the scaffold's `wsRoute` helper in `src/server/realtime-routes.ts` deliberately accepts tokenless WebSocket connections so that apps with anonymous viewers work out of the box. For a fully private app, replace its optional-token block with a hard requirement: ```ts // src/server/realtime-routes.ts - inside wsRoute(), replacing the optional-token block if (!token) return new Response('Unauthorized', { status: 401 }) const auth = (await verifyJwt(jwtConfig(c.env), token)).result if (!auth) return new Response('Unauthorized', { status: 401 }) ``` That single edit gates every route that uses the helper. **Never treat `<AuthGate>` as server authorization.** It stops the UI from rendering, not the WebSocket from upgrading - anyone can open a socket against `/ws/:roomId` directly. The server boundary is `wsRoute`'s token check plus the `'*'` rules in your [permissions](/concepts/permissions). **Best for:** internal tools, paid SaaS, anything where every route requires sign-in. Fully public Delete the scaffold's `src/pages/(app)/(protected)/` folder (or remove the `<AuthGate>` from its `_layout.tsx`) so no route is gated. Keep `<RecordProvider allowAnonymous>` - it's already on in the scaffold's `(app)/_layout.tsx`. Signed-out visitors get the records that the `'*'` role rule in [permissions](/concepts/permissions) grants read access to. **Best for:** marketing sites, public catalogs, read-only directories. ## The `AuthBoot` helper The scaffold's `(app)/_layout.tsx` mounts the data layer through a local `AuthBoot` helper defined in that file. It is **not** the SDK's `<AuthGate>`, and it gates nothing. Its contract: * It waits for `useAuthStatus().isLoaded`, so the data layer always mounts with resolved auth state. While the session check is in flight it renders a fixed theme-colored panel, not a spinner. * It then mounts `<RecordProvider allowAnonymous>` and `<RecordScope>` for signed-in **and** signed-out users - public pages render inside it. * Its `onWriteError` wiring routes server-rejected fire-and-forget writes to toasts (`permission` denials as warnings, everything else as errors). That callback is the reporting surface for rejected plain writes; use a `*Confirmed` mutation when the caller needs an awaited result. Keep `AuthBoot` and its `onWriteError` wiring when you customize the layout. Express gating with `<AuthGate>` - route-scoped in `(protected)/_layout.tsx`, or around `AuthBoot` for a fully gated app - rather than by conditioning `AuthBoot` itself. ## Auth-state checks in components Use [`useAuth().isSignedIn`](/sdk-reference/client/auth#useauth-authstate) for the "is the user signed in?" check. It updates immediately on sign-in and sign-out: ```tsx import { useAuth } from 'deepspace' function MyComponent() { const { isLoaded, isSignedIn, userId } = useAuth() if (!isLoaded) return <Skeleton /> if (!isSignedIn) return <SignInPrompt /> return <SignedInView userId={userId} /> } ``` Don't gate on [`useUser().user`](/sdk-reference/client/auth#useuser-user-isloading-refetch) truthiness alone. `useUser` returns the storage-layer profile (karma, credits, room role) and loads async, so you'll get a flash of "not signed in" while the profile fetch resolves. Use `useAuth().isSignedIn` for state checks and reach for `useUser()` only when you need profile fields. ## `<AuthGate>` props | Prop | Type | Description | | ------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fallback` | `ReactNode` | UI shown to first-visit signed-out users. Defaults to `<AuthOverlay />` rendered without `onClose`, which makes it non-dismissible. Not used when the user signs out mid-session - see `redirectOnSignOut`. | | `redirectOnSignOut` | `string` | Where the user lands when they sign out from inside the gate. Defaults to `'/'`. Triggers a full-page reload so cached state can't leak. | Pass a custom fallback to render something other than the default overlay: ```tsx import { AuthGate } from 'deepspace' <AuthGate fallback={<TeaserPage />}> <Dashboard /> </AuthGate> ``` ## Sign-in UI `<AuthOverlay />` is a styled modal sign-in component: ```tsx import { AuthOverlay, useAuth } from 'deepspace' function App() { const { isSignedIn } = useAuth() return ( <> <MainContent /> {!isSignedIn && <AuthOverlay providers={['google', 'github']} />} </> ) } ``` Render `<AuthOverlay />` without an `onClose` prop and gate on `!isSignedIn`. It auto-hides when the user signs in. ### Providers By default `<AuthOverlay />` shows GitHub, Google, and email/password. The `providers` prop controls only the OAuth buttons - its type is `Array<'github' | 'google'>`. Email/password sign-in is always rendered: ```tsx <AuthOverlay providers={['google']} /> // Google + email/password <AuthOverlay providers={['google', 'github']} /> // Google + GitHub + email/password ``` The email/password field **signs in** an existing account; it is not a signup form. Public email signup is closed platform-wide, so in practice the accounts that use it are the `@deepspace.test` [test accounts](/guides/testing#provisioning-test-accounts) your Playwright specs drive. Your app's real users, and your own DeepSpace account, arrive through GitHub or Google. Signing in **yourself**, to the CLI, is a different thing from signing in your app's users: `deepspace auth login` is browser OAuth only, and the stored session is what an agent or CI job reuses. See [login state](/cli-reference/overview#login-state). ## Conditional rendering For small one-off bits of UI, [`<SignedIn>`](/sdk-reference/client/auth#signedin-and-signedout) and `<SignedOut>` are shorthand for the `isSignedIn` branch: ```tsx import { SignedIn, SignedOut } from 'deepspace' <SignedIn> <UserMenu /> </SignedIn> <SignedOut> <SignInButton /> </SignedOut> ``` ## Signing out Call [`signOut`](/sdk-reference/client/auth#signin-signout) - a thin re-export of Better Auth's client method: ```tsx import { signOut } from 'deepspace' <button onClick={() => signOut()}>Sign out</button> ``` The scaffolded `Navigation.tsx` already calls `signOut()` from the avatar dropdown. Extend the existing one rather than adding a second sign-out control. If your app requires sign-in, keep a sign-out control reachable in the signed-in UI. If you replace `Navigation.tsx`, wire `signOut()` into the new shell. ## Server-side verification For custom API routes that aren't auto-protected, verify the JWT yourself. Add the handler to the existing Hono `app` in the scaffold's `worker.ts` - `verifyJwt` is already imported there, so the `import` line below is redundant if you're extending `worker.ts` in place: ```ts // worker.ts import { verifyJwt } from 'deepspace/worker' app.get('/api/me', async (c) => { const auth = c.req.header('Authorization') ?? '' const token = auth.replace(/^Bearer\s+/i, '') const outcome = await verifyJwt({ publicKey: c.env.AUTH_JWT_PUBLIC_KEY, issuer: c.env.AUTH_JWT_ISSUER, }, token) if (!outcome.result) return c.json({ error: 'unauthorized' }, 401) return c.json({ userId: outcome.result.userId, claims: outcome.result.claims }) }) ``` `verifyJwt` never throws. It returns `{ result, error?, debug? }`: `result` is `{ userId, claims }` on success or `null` on failure, `error` is the underlying jose error, and `debug` is the decoded `iss`/`aud`/`azp`/`exp` for log lines. Always check `result` before reading the subject. The scaffold's `wsRoute` handler already calls `verifyJwt` for every WebSocket upgrade, so you only need this pattern for custom HTTP routes. ## Common patterns ### Landing page with an "open app" CTA ```tsx import { Link } from 'react-router-dom' import { useAuth } from 'deepspace' function Landing() { const { isSignedIn } = useAuth() return ( <> <Hero /> <Pricing /> {isSignedIn ? <Link to="/dashboard">Open the app</Link> : <SignInButton />} </> ) } ``` ### Conditional nav links The scaffold's `Navigation.tsx` filters `src/nav.ts` by the user's room role. Omit `roles` to show an item to everyone; admins see everything regardless. ```ts // src/nav.ts import type { Role } from './constants' export interface NavItem { path: string label: string roles?: Role[] } export const nav: NavItem[] = [ { path: '/home', label: 'Home' }, // visible to everyone { path: '/settings', label: 'Settings' }, // visible to everyone { path: '/admin', label: 'Admin', roles: ['admin'] },// admin-only ] ``` `Role` is defined in `src/constants.ts` - extend it there to add new roles, then use them in [permissions](/concepts/permissions). ### Hiding nav on the landing route If your landing page has its own header, gate the global `<Navigation />` on `useLocation()` inside the scaffold's existing `_app.tsx`. Keep the surrounding providers - only the `<Navigation />` line changes: ```tsx // src/pages/_app.tsx - inside the existing App() return import { useLocation } from 'react-router-dom' const isLanding = useLocation().pathname === '/' // ...replace <Navigation /> with: {!isLanding && <Navigation />} ``` ## Troubleshooting \`useToast must be used within ToastProvider\` on import The scaffold's `_app.tsx` wraps the tree in the **local** `ToastProvider` (from `src/components/ui`), not the SDK's. Import `useToast` from `../components/ui`, not from `deepspace`. Mixing the two contexts produces this error. Page is blank for signed-out visitors A `<RecordProvider>` without `allowAnonymous` renders **nothing** when the visitor is signed out. On localhost the SDK swaps in a signed-out diagnostic box that names exactly this cause; a deployed app just shows a blank page. Either add `allowAnonymous` to make the page publicly viewable, or gate the route behind `<AuthGate>` so signed-out visitors get the sign-in fallback instead of nothing. Safari shows the user as signed-out even after sign-in Safari refuses to set `__Secure-` cookies on `localhost` because the attribute requires HTTPS; Chrome is more lenient. Test against `https://` URLs (or deploy) when verifying Safari behavior. Sign-in popup opens but never returns to the app The OAuth flow opens the auth worker at the platform domain, completes sign-in, then redirects back to your app. If the redirect doesn't fire, it's almost always a cookie or HTTPS issue (see above) - open the auth worker's tab and check the browser console. ## Next steps * [Permissions](/concepts/permissions) - how role-based access control works on collections. * [Server actions](/guides/server-actions) - privileged worker code that bypasses user RBAC. * [Auth reference](/sdk-reference/client/auth) - all auth hooks, providers, and components. * [Worker auth reference](/sdk-reference/worker/auth) - `verifyJwt` and HMAC primitives. Source: /guides/authentication.md --- # Data storage Define a collection, query it from React, and persist realtime writes. This guide walks through adding a new [collection](/concepts/data-model) to your app: declaring its [schema](/sdk-reference/worker/schemas), querying it from the client, and persisting writes. By the end, you'll have a working CRUD page backed by a [Durable Object](/concepts/architecture#durable-objects) that syncs in real time across every connected client. For background on envelopes, scopes, and the mutation pipeline, see [Data model](/concepts/data-model) and [Real-time sync](/concepts/realtime-sync). ## Define the schema Schemas live under `src/schemas/`, one file per collection. Create a new collection called `notes`: ```ts // src/schemas/notes-schema.ts import type { CollectionSchema } from 'deepspace/schema' export const notesSchema: CollectionSchema = { name: 'notes', columns: [ { name: 'title', storage: 'text', interpretation: 'plain' }, { name: 'body', storage: 'text', interpretation: 'plain' }, { name: 'pinned', storage: 'number', interpretation: { kind: 'boolean' } }, ], permissions: { member: { read: true, create: true, update: 'own', delete: 'own' }, admin: { read: true, create: true, update: true, delete: true }, }, } ``` Register it in `src/schemas.ts`: ```ts import type { CollectionSchema } from 'deepspace/schema' import { usersSchema } from './schemas/users-schema' import { settingsSchema } from './schemas/admin-schema' import { notesSchema } from './schemas/notes-schema' export const schemas: CollectionSchema[] = [usersSchema, settingsSchema, notesSchema] ``` Restart `npx deepspace dev start` if it's running - schemas are picked up at worker startup. Schemas are baked into your worker at deploy time. There is no runtime schema registry; to add or change a schema in production, redeploy. ## Read records [`useQuery`](/sdk-reference/client/records#usequery-t-collection-options) subscribes to a collection and streams updates over a WebSocket. Each record arrives as an envelope, with your fields under `.data`: ```tsx import { useQuery } from 'deepspace' type Note = { title: string; body: string; pinned: boolean } function NotesList() { const { records, status, error } = useQuery<Note>('notes', { orderBy: 'createdAt', orderDir: 'desc', }) if (status === 'loading') return <p>Loading…</p> if (status === 'error') return <p>Error: {error}</p> return ( <ul> {records.map((note) => ( <li key={note.recordId}> <h3>{note.data.title}</h3> <p>{note.data.body}</p> </li> ))} </ul> ) } ``` User fields live under `.data`. Reading `note.title` returns `undefined` - always use `note.data.title`. TypeScript catches this if you pass a row type to `useQuery<Note>`. For filtering, sorting, and limit options, see [`useQuery` options](/sdk-reference/client/records#usequery-t-collection-options). The Durable Object applies `where` server-side before broadcasting, so unauthorized records never leave the worker. ## Write records [`useMutations`](/sdk-reference/client/records#usemutations-t-collection) returns fire-and-forget `create` / `put` / `remove` functions plus `*Confirmed` variants. Query state updates when the accepted server broadcast returns. ```tsx import { useMutations } from 'deepspace' const { create, put, remove } = useMutations<Note>('notes') const id = await create({ title: 'Untitled', body: '', pinned: false }) await put(id, { pinned: true }) // merge: only updates pinned await remove(id) ``` * **`create` returns the `recordId` immediately.** The ID is generated client-side (timestamp + random suffix) before the write is sent, so you can navigate to `/notes/${id}` without waiting for the server. * **`put` is merge semantics.** The server applies `{ ...existing, ...patch }`. Send only the fields you're changing - don't spread the whole row. * **`remove` is a hard delete.** There's no soft-delete primitive at the records layer. * **Use `createConfirmed` when persistence matters before the next step.** Plain `create` returns the client-generated ID before the server answers. `createConfirmed` waits for the Durable Object ack and rejects the promise on RBAC or validation denial. See the [reference](/sdk-reference/client/records) for the full method table. ## A complete CRUD example ```tsx import { useState } from 'react' import { useQuery, useMutations } from 'deepspace' import { useToast } from '../components/ui' type Note = { title: string; body: string; pinned: boolean } export default function NotesPage() { const { records, status } = useQuery<Note>('notes', { orderBy: 'createdAt', orderDir: 'desc' }) const { create, put, remove } = useMutations<Note>('notes') const { success, error } = useToast() const [draft, setDraft] = useState('') async function addNote() { if (!draft.trim()) return try { await create({ title: draft, body: '', pinned: false }) setDraft('') success('Note created') } catch (e) { error('Could not create note', String(e)) } } if (status === 'loading') return <p>Loading…</p> return ( <div> <input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="New note…" /> <button onClick={addNote}>Add</button> <ul> {records.map((note) => ( <li key={note.recordId}> <input type="checkbox" checked={note.data.pinned} onChange={(e) => put(note.recordId, { pinned: e.target.checked })} /> {note.data.title} <button onClick={() => remove(note.recordId)}>Delete</button> </li> ))} </ul> </div> ) } ``` Open the page in two browser windows - changes in one window appear in the other in real time. ## JSON columns For structured field data, use `interpretation: { kind: 'json' }`: ```ts { name: 'tags', storage: 'text', interpretation: { kind: 'json' } } ``` The SDK serializes on write and parses on read - pass and receive the value directly, no `JSON.stringify` / `JSON.parse` on either end. ```ts await create({ title: 'Trip', tags: ['vacation', '2024'] }) // later record.data.tags // ['vacation', '2024'] ``` ## Per-user privacy Permissions are enforced server-side. To make notes private per user: ```ts permissions: { member: { read: 'own', create: true, update: 'own', delete: 'own' }, } ``` `'own'` resolves against `record.createdBy` by default; override with `ownerField` if a different column determines ownership. For published/draft visibility, collaborators, and team rules, see [Permissions](/concepts/permissions). ## Performance tips * **Filter at the query.** `where` runs server-side, so unauthorized or unwanted rows never cross the wire. * **Use `limit` on long lists.** Initial subscription sends every matching record. For thousands of rows, slice by date or category. (Cursor pagination is on the roadmap.) * **Lift `useQuery` to a parent.** Identical queries deduplicate to one WebSocket subscription, but each mount still re-renders on every store change - fetch once at the top of the page and pass records down via props. * **Patch, don't replace.** `put` with a partial patch keeps the wire payload small and avoids overwriting concurrent edits. The SDK lints each schema at boot and prints findings prefixed `[schema-lint]` to the worker console. See [Schema-lint warnings](/sdk-reference/worker/schemas#schema-lint-warnings) for the three shapes and their fixes. ## Next steps * [Permissions](/concepts/permissions) - role-based access control on collections. * [Server actions](/guides/server-actions) - privileged writes that bypass user RBAC. * [Records reference](/sdk-reference/client/records) - full hooks API surface. * [Schema reference](/sdk-reference/worker/schemas) - column types and permission rules. Source: /guides/data-storage.md --- # Messaging Build public real-time chat with channels, reactions, and read receipts. DeepSpace ships a small public-chat layer as drop-in schemas plus React hooks. Add the schemas to your app-owned `RecordRoom`, then compose the UI from `useChannels`, `useMessages`, and `useReactions`. The bundled schemas model **public channels only**. They do not provide private channels, direct messages, invitations, or a platform-owned conversation directory. If your app needs private messaging, define app-specific schemas with a participant or collaborator field and enforce access in the worker with `collaboratorsField` and `read: 'shared'`. ## Add the schemas Import schemas from the runtime-neutral `deepspace/schema` entry point whenever browser or shared code also imports the schema array. ```ts // src/schemas.ts import type { CollectionSchema } from 'deepspace/schema' import { CHANNELS_SCHEMA, MESSAGES_SCHEMA, REACTIONS_SCHEMA, CHANNEL_MEMBERS_SCHEMA, READ_RECEIPTS_SCHEMA, } from 'deepspace/schema' import { usersSchema } from './schemas/users-schema' export const schemas: CollectionSchema[] = [ usersSchema, CHANNELS_SCHEMA, MESSAGES_SCHEMA, REACTIONS_SCHEMA, CHANNEL_MEMBERS_SCHEMA, READ_RECEIPTS_SCHEMA, ] ``` The five collections are `channels`, `messages`, `reactions`, `channel-members`, and `read-receipts`. They live in your app's room; DeepSpace does not create a shared global messaging database. ## Build the chat surface ```tsx import { useState } from 'react' import { useChannels, useMessages, useReactions } from 'deepspace' export default function ChatApp() { const { channels, status } = useChannels() const [activeId, setActiveId] = useState<string>() if (status === 'loading') return <SkeletonList /> return ( <div className="grid h-screen grid-cols-[240px_1fr]"> <aside> {channels.map((channel) => ( <button key={channel.recordId} onClick={() => setActiveId(channel.recordId)}> #{channel.data.name} </button> ))} </aside> <main>{activeId && <ChannelView channelId={activeId} />}</main> </div> ) } function ChannelView({ channelId }: { channelId: string }) { const { messages, send, softDelete } = useMessages(channelId) const { toggle, getReactionsForMessage } = useReactions(channelId) const [draft, setDraft] = useState('') return ( <> <ul> {messages.map((message) => ( <li key={message.recordId}> <span>{message.data.authorId}</span> <time>{new Date(message.createdAt).toLocaleTimeString()}</time> <p>{message.data.deleted ? '[deleted]' : message.data.content}</p> {getReactionsForMessage(message.recordId).map((reaction) => ( <button key={reaction.emoji} onClick={() => toggle(message.recordId, reaction.emoji)} > {reaction.emoji} {reaction.count} </button> ))} <button onClick={() => softDelete(message.recordId)}>Delete</button> </li> ))} </ul> <form onSubmit={(event) => { event.preventDefault() if (!draft.trim()) return send(draft) setDraft('') }} > <input value={draft} onChange={(event) => setDraft(event.target.value)} /> <button>Send</button> </form> </> ) } ``` The author is derived from the verified session. Sends, edits, reactions, and deletes are fire-and-forget: query state changes only after the room accepts the write and broadcasts it. Handle rejected writes with `RecordProvider.onWriteError`. Prefer `softDelete(messageId)` over `remove(messageId)` so thread links remain coherent. ## Create a channel All bundled channels are public, so `create` only needs a name and optional description. ```tsx const { create } = useChannels() const channelId = await create({ name: 'general', description: 'Company-wide announcements', }) ``` ## Membership Membership is useful for public opt-in UX. `join` and `leave` use confirmed mutations, so their promises settle only after the room accepts or rejects the write. ```tsx import { useState } from 'react' import { useChannelMembers } from 'deepspace' function MembershipButton({ channelId }: { channelId: string }) { const { isMember, join, leave } = useChannelMembers(channelId) const [pending, setPending] = useState(false) const [error, setError] = useState<string | null>(null) const changeMembership = async () => { setPending(true) setError(null) try { if (isMember) await leave() else await join() } catch (cause) { setError(cause instanceof Error ? cause.message : 'Membership update failed') } finally { setPending(false) } } return ( <div> <button disabled={pending} onClick={() => void changeMembership()}> {pending ? 'Saving…' : isMember ? 'Leave' : 'Join'} </button> {error && <p role="alert">{error}</p>} </div> ) } ``` Membership does not make a channel private. The bundled message schema remains readable to every room member. ## Read receipts ```tsx import { useEffect } from 'react' import { useMessages, useReadReceipts } from 'deepspace' function ChannelUnread({ channelId }: { channelId: string }) { const { messages } = useMessages(channelId) const { markAsRead, getUnreadCount } = useReadReceipts() useEffect(() => { markAsRead(channelId) }, [channelId, messages.length, markAsRead]) return <span>{getUnreadCount(channelId, messages)} unread</span> } ``` `markAsRead` stores the current timestamp for the signed-in user. Receipt rows are private to that user under the bundled schema. ## App-specific private messaging Keep private messaging inside the app that owns it. Define a collection with immutable participant IDs, set it as `collaboratorsField`, and use `read: 'shared'` so the worker—not UI filtering—enforces access. You can give each thread an app-owned room ID such as `chat:${threadId}` when separate Durable Objects are useful. Expose an authenticated API if another app needs access. ## Next steps * [Messaging reference](/sdk-reference/client/messaging) — exact hook signatures. * [Permissions](/concepts/permissions) — server-enforced private rows. * [Presence and cursors](/guides/presence-and-cursors) — typing indicators and online status. Source: /guides/messaging.md --- # Collaborative editing Sync text and structured fields across clients with Yjs. Collaborative editing in DeepSpace is backed by [Yjs](https://yjs.dev/), a CRDT library. The SDK ships three hooks for binding Yjs documents to React state. * [`useYjsText`](/sdk-reference/client/realtime#useyjstext-collection-recordid-fieldname) - collaborative plain text bound to a record field. * [`useYjsField`](/sdk-reference/client/realtime#useyjsfield-collection-recordid-fieldname) - raw `Y.Doc` access for structured types (maps, arrays, XML fragments) on a record field. * [`useYjsRoom`](/sdk-reference/client/realtime#useyjsroom-docid-fieldname) - a standalone Yjs document not tied to any record. `useYjsText` and `useYjsField` sync over the record's existing `RecordRoom` connection. `useYjsRoom` opens a dedicated `YjsRoom` Durable Object per `docId`. See the [real-time reference](/sdk-reference/client/realtime) for full signatures. Record-bound hooks (`useYjsText`, `useYjsField`) require a `<RecordProvider>` ancestor and a registered collection schema with an existing record. The scaffold from `npm create deepspace` sets up the provider. `useYjsRoom` has neither requirement and connects directly to the `YjsRoom` DO. ## Collaborative text Bind a `<textarea>` to a Yjs `Y.Text` on a record. Two clients editing the same `(collection, recordId, fieldName)` see each other's keystrokes live. ```tsx import { useYjsText } from 'deepspace' function DocEditor({ docId }: { docId: string }) { const { text, setText, synced, canWrite } = useYjsText('docs', docId, 'body') return ( <textarea value={text} onChange={(e) => setText(e.target.value)} disabled={!synced || !canWrite} /> ) } ``` Disable the input until `synced` is `true` so the user doesn't type into the document before initial state arrives. `canWrite` mirrors the collection's RBAC - see [Permissions](/concepts/permissions). ## Structured data For lists, maps, or any non-text Yjs type, use [`useYjsField`](/sdk-reference/client/realtime#useyjsfield-collection-recordid-fieldname). It returns the raw `Y.Doc` plus an `updateCount` that ticks on every local or remote update - use it as a render trigger. ```tsx import { useEffect, useState } from 'react' import { useYjsField } from 'deepspace' type Card = { id: string; title: string } function KanbanBoard({ boardId }: { boardId: string }) { const { doc, synced, canWrite, updateCount } = useYjsField('boards', boardId, 'cards') const cardsArray = doc.getArray<Card>('cards') const [cards, setCards] = useState<Card[]>(() => cardsArray.toArray()) useEffect(() => { setCards(cardsArray.toArray()) }, [cardsArray, updateCount]) function moveCard(from: number, to: number) { if (!canWrite) return doc.transact(() => { const card = cardsArray.get(from) if (!card) return cardsArray.delete(from, 1) cardsArray.insert(to, [card]) }) } return ( <div> {cards.map((card, i) => ( <CardView key={card.id} card={card} onMove={(to) => moveCard(i, to)} /> ))} </div> ) } ``` The hook returns the `Y.Doc`, not a value. Read whichever Yjs type you need off `doc` (`doc.getArray`, `doc.getMap`, `doc.getXmlFragment`) and rely on `updateCount` or a Yjs `observe` to drive React re-renders. For simple text, prefer `useYjsText`. ## Standalone rooms Use [`useYjsRoom`](/sdk-reference/client/realtime#useyjsroom-docid-fieldname) for documents not tied to a record: scratchpads, whiteboards, ephemeral sessions. The first argument is any string `docId`; two clients passing the same `docId` connect to the same `YjsRoom` Durable Object. ```tsx import { useYjsRoom } from 'deepspace' type Shape = { id: string; x: number; y: number } function Whiteboard({ sessionId }: { sessionId: string }) { const { doc, synced, canWrite } = useYjsRoom(sessionId, 'notes') const shapes = doc.getArray<Shape>('shapes') function addShape(shape: Shape) { if (!canWrite) return shapes.push([shape]) } if (!synced) return <p>Loading...</p> return <Canvas shapes={shapes.toArray()} onAdd={addShape} /> } ``` The second argument names a `Y.Text` field exposed as `text` / `setText` on the hook return - useful when the room is mostly a text doc. For non-text data, ignore those fields and reach into `doc` directly as shown above. ## YjsRoom authentication and roles The `/ws/yjs/:docId` route in the scaffold is the only `/ws/*` route that is **token-required and docs-aware**. The other `/ws/*` routes use the inline `wsRoute` helper, which allows anonymous connections (see [Security model](/concepts/architecture#security-model-websocket-identity)). The Yjs handler instead: 1. Returns `401` if no verified JWT is present on the upgrade. 2. Looks up `documents[docId]` (the docs feature's collection) and resolves a Yjs role from the row: * `admin` when the caller is `documents.ownerId` or the app's `OWNER_USER_ID`. * `member` when the caller's id is in `documents.editors` (JSON array stored as text). * `viewer` when the caller's id is in `documents.collaborators` (JSON array stored as text). * `403` otherwise. 3. When the app has no `documents` collection registered (or the row is not found), the handler falls through to `'member'` for any authenticated user, so apps that use `useYjsRoom` without the docs feature still work. The resolved `role` is appended to the DO URL as a query parameter; the DO uses it to set `canWrite` (admins and members write, viewers are read-only). **Don't replace the `/ws/yjs/:docId` handler with a bare `wsRoute` when adding the docs feature.** The role resolution above lives only in the dedicated handler. Swapping it for `wsRoute((env) => env.YJS_ROOMS)` makes every authenticated caller anonymous-equivalent in the DO's eyes - the visible bug is "everyone is a viewer" or "collaborators can't type," because the DO defaults to the most restrictive role when no `role` parameter arrives. ## Editor integrations The Yjs ecosystem has bindings for popular editors. The SDK doesn't ship them, but the hooks expose the underlying `Y.Doc` and `awareness`, which is everything these bindings need. * [Tiptap](https://tiptap.dev/docs/editor/getting-started/install/collaboration) - rich-text editor with Yjs collaboration. * [ProseMirror](https://github.com/yjs/y-prosemirror) - the `y-prosemirror` binding. * [Monaco](https://github.com/yjs/y-monaco) - `y-monaco` for code editors. ```tsx import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Collaboration from '@tiptap/extension-collaboration' import CollaborationCursor from '@tiptap/extension-collaboration-cursor' import { useYjsField } from 'deepspace' function RichTextEditor({ docId }: { docId: string }) { const { doc, awareness } = useYjsField('docs', docId, 'body') const editor = useEditor({ extensions: [ StarterKit.configure({ history: false }), Collaboration.configure({ document: doc, field: 'prosemirror' }), CollaborationCursor.configure({ provider: { awareness } }), ], }) return <EditorContent editor={editor} /> } ``` Install `@tiptap/extension-collaboration-cursor` alongside Tiptap if you want peer cursors; pass `awareness` from the hook through its `provider` option. ## Cursors and selections Yjs's awareness protocol broadcasts ephemeral peer state - cursors, selections, names. Both `useYjsField` and `useYjsRoom` expose an `awareness` instance. Pass it to an editor binding (like `@tiptap/extension-collaboration-cursor` shown above), or read and write it directly when building a custom UI. The awareness instance returned from `useYjsRoom` is already wired to the same WebSocket: any call to `awareness.setLocalState` or `awareness.setLocalStateField` triggers an `MSG_AWARENESS` frame to peers, and incoming peer frames update the map you read with `awareness.getStates()`. ```tsx import { useEffect, useState } from 'react' import { useYjsRoom } from 'deepspace' type CursorState = { line: number; col: number } function CursorAwareEditor({ docId }: { docId: string }) { const { text, setText, awareness, synced } = useYjsRoom(docId, 'body') const [peers, setPeers] = useState<Array<[number, CursorState]>>([]) // Broadcast our cursor on every change. function handleCaretChange(line: number, col: number) { awareness.setLocalStateField('cursor', { line, col } satisfies CursorState) } // Read every peer's state. The map is keyed by Yjs clientID. useEffect(() => { const update = () => { const next: Array<[number, CursorState]> = [] awareness.getStates().forEach((state, clientId) => { if (clientId === awareness.clientID) return const cursor = (state as { cursor?: CursorState }).cursor if (cursor) next.push([clientId, cursor]) }) setPeers(next) } awareness.on('change', update) update() return () => awareness.off('change', update) }, [awareness]) if (!synced) return <p>Loading...</p> return <Editor text={text} onText={setText} onCaret={handleCaretChange} peers={peers} /> } ``` Conventional fields are `cursor`, `selection`, `user` (`{ name, color }`), and `typing`. Anything you put under a key with `setLocalStateField` shows up in every peer's `getStates()` map until you clear it or disconnect. The docs feature's Tiptap toolbar wires the same `awareness` instance into `@tiptap/extension-collaboration-cursor`, which handles the cursor/selection encoding for you - see [Editor integrations](#editor-integrations) above. For the raw protocol primitives (`MSG_AWARENESS`, `encodeAwarenessMessage`, `handleAwarenessMessage`), see [low-level sync primitives](/sdk-reference/client/realtime#low-level-sync-primitives) in the reference. For non-Yjs presence (live cursors on a canvas, who's typing in a chat), use [`usePresenceRoom`](/guides/presence-and-cursors) instead - a separate, lighter-weight room type. ## When to use Yjs vs records DeepSpace gives you two ways to share data. Pick based on what you're modeling. | Use records when… | Use Yjs when… | | --------------------------------------------------------- | --------------------------------------------- | | Each row is a discrete entity (a todo, a message, a user) | The data is a single document being co-edited | | Fields are queryable and filterable | The data has interactive merging needs | | RBAC needs to filter visibility | All editors should see the same content | | Updates are coarse-grained | Updates are character-by-character | Many apps use both - records for the list of documents, Yjs for the content of each document. ## Persistence Record-bound Yjs fields (`useYjsText`, `useYjsField`) persist inside the record's `RecordRoom`. Standalone documents (`useYjsRoom`) persist inside a `YjsRoom` DO keyed by `docId`. In both cases the document is stored as a single SQLite blob that's re-encoded from the current `Y.Doc` state on every update, so the blob grows with the document's current size, not with edit history. New clients receive the full state on connection, and documents survive worker redeployments. ## Next steps * [Real-time reference](/sdk-reference/client/realtime) - full hook signatures and return shapes. * [Canvas](/guides/canvas) - collaborative shapes and viewports via `useCanvas`. * [Presence and cursors](/guides/presence-and-cursors) - live cursors and typing indicators. * [Real-time sync](/concepts/realtime-sync) - how sync works under the hood. Source: /guides/collaborative-editing.md --- # Presence and cursors Show who's online and broadcast live cursor, typing, and viewport state. Show who else is in a room and where they're looking. DeepSpace exposes two distinct primitives for this. * [`usePresence`](/sdk-reference/client/realtime#usepresence-options) - derived online/offline status from heartbeats stored on the users collection. Persistent. \~60s granularity. * [`usePresenceRoom`](/sdk-reference/client/realtime#usepresenceroom-scopeid) - high-frequency ephemeral state (cursors, typing, viewport) broadcast through a dedicated Durable Object. In-memory only. ## Online status `usePresence` reads `lastSeenAt` from the users collection in the current `RecordScope` and sends a heartbeat every 60 seconds. A user is "online" if their last heartbeat was within `timeoutMs` (default 5 minutes). ```tsx import { usePresence } from 'deepspace' function OnlineList() { const { users, isOnline, getLastSeen } = usePresence() return ( <ul> {users.map((u) => ( <li key={u.id}> <span className={isOnline(u.id) ? 'online' : 'offline'}>●</span> {u.name} {!isOnline(u.id) && getLastSeen(u.id) && ( <small>last seen {new Date(getLastSeen(u.id)!).toLocaleString()}</small> )} </li> ))} </ul> ) } ``` Heartbeats are written to the users collection, so they're visible to anyone with read access to users. The data persists. Use `usePresence` for "currently online" lists where 60-second granularity is acceptable. See [`usePresence`](/sdk-reference/client/realtime#usepresence-options) for the full return shape and options. ## Live cursors and typing For sub-second cursor or typing indicators, use `usePresenceRoom`. It connects to a dedicated `PresenceRoom` Durable Object that holds peer state in memory only - nothing is persisted, and disconnecting removes you from `peers` on every other client within a second. ```tsx import { usePresenceRoom } from 'deepspace' function Canvas({ canvasId }: { canvasId: string }) { const { peers, updateState } = usePresenceRoom(`canvas:${canvasId}`) return ( <div onMouseMove={(e) => updateState({ cursor: { x: e.clientX, y: e.clientY } })} > {peers.map((peer) => { const cursor = peer.state.cursor as { x: number; y: number } | undefined if (!cursor) return null return ( <div key={peer.userId} style={{ position: 'absolute', left: cursor.x, top: cursor.y }} > {peer.userName} </div> ) })} </div> ) } ``` `peers` excludes the current user. Identity fields (`userId`, `userName`, etc.) come from the verified JWT - peers can't spoof them. See [`usePresenceRoom`](/sdk-reference/client/realtime#usepresenceroom-scopeid) for the full peer shape. **`peers` can be empty on the first render even when someone else is already there.** The initial sync may arrive before the room has finished accounting for existing members; those peers then appear a moment later as individual joins. Render `peers` reactively — which the example above already does — rather than reading it once on mount and deciding the room is empty. ### Scoping rooms The first argument to `usePresenceRoom` is any string. Two clients passing the same scope ID connect to the same `PresenceRoom` instance and see each other. ```tsx usePresenceRoom(`canvas:${canvasId}`) // Per-canvas cursors usePresenceRoom(`doc:${docId}`) // Per-document presence usePresenceRoom(`thread:${channelId}`) // Per-channel typing ``` ### State merging `updateState` shallow-merges into your peer's state object, so you can broadcast multiple kinds of state independently: ```tsx updateState({ cursor: { x, y } }) // just cursor updateState({ typing: true }) // just typing updateState({ cursor: { x, y }, typing: true }) // both ``` To clear a state field, set it explicitly to `false` or `null` - omitting it leaves the previous value intact. ### Throttling cursor updates `onMouseMove` fires far more often than you need to broadcast. Without throttling, you'll flood the room with messages. A `requestAnimationFrame` gate works: ```tsx const pending = useRef<{ x: number; y: number } | null>(null) const scheduled = useRef(false) function handleMove(e: React.MouseEvent) { pending.current = { x: e.clientX, y: e.clientY } if (scheduled.current) return scheduled.current = true requestAnimationFrame(() => { scheduled.current = false if (pending.current) updateState({ cursor: pending.current }) }) } ``` For typing indicators, use a trailing timeout to clear the flag after the user stops typing. ### Disconnects and reconnects The hook reconnects automatically. When the WebSocket drops, `connected` flips to `false` and your peer is removed from every other client's `peers` list. On reconnect you re-join with empty state - you must re-send your cursor or typing flag if you want it visible again. Use `connected` to dim or hide your own optimistic state during the gap. ## User colors Cursor displays use a stable color per user. ```tsx import { getUserColor } from 'deepspace' const color = getUserColor(peer.userId) // deterministic hash → palette index ``` The same userId always returns the same color. Pass a custom palette for brand colors. See [`getUserColor`](/sdk-reference/client/realtime#user-colors). ## Typing indicator ```tsx import { usePresenceRoom } from 'deepspace' import { useRef } from 'react' function MessageInput({ channelId, value, onChange }: { channelId: string value: string onChange: (v: string) => void }) { const { peers, updateState } = usePresenceRoom(`thread:${channelId}`) const typingTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined) function handleChange(v: string) { onChange(v) updateState({ typing: true }) clearTimeout(typingTimer.current) typingTimer.current = setTimeout(() => updateState({ typing: false }), 1500) } const typers = peers.filter((p) => p.state.typing).map((p) => p.userName) return ( <> <input value={value} onChange={(e) => handleChange(e.target.value)} /> {typers.length > 0 && <p>{typers.join(', ')} typing…</p>} </> ) } ``` ## Cursor overlay ```tsx import { usePresenceRoom, getUserColor } from 'deepspace' function CursorOverlay({ scope }: { scope: string }) { const { peers } = usePresenceRoom(scope) return ( <> {peers.map((peer) => { const cursor = peer.state.cursor as { x: number; y: number } | undefined if (!cursor) return null const color = getUserColor(peer.userId) return ( <div key={peer.userId} style={{ position: 'absolute', left: cursor.x, top: cursor.y, pointerEvents: 'none', color, }} > <div style={{ width: 8, height: 8, borderRadius: '50%', background: color, }} /> <span>{peer.userName}</span> </div> ) })} </> ) } ``` ## Choosing a primitive | Primitive | Use when | Persistence | | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------- | | [`usePresenceRoom`](/sdk-reference/client/realtime#usepresenceroom-scopeid) | Cursors, typing, viewport pings, "active on this page" indicators | In-memory, ephemeral | | Yjs `awareness` (via [`useYjsField`](/sdk-reference/client/realtime) / [`useYjsRoom`](/sdk-reference/client/realtime)) | Editor cursors and selections that must stay in sync with CRDT state over the same socket | In-memory, ephemeral | ## Next steps * [Canvas](/guides/canvas) - real-time shapes and viewports on `useCanvas`. * [Collaborative editing](/guides/collaborative-editing) - Yjs-backed text and rich data. * [Real-time sync](/concepts/realtime-sync) - how sync works under the hood. * [Realtime reference](/sdk-reference/client/realtime) - full hook signatures and return shapes. Source: /guides/presence-and-cursors.md --- # Canvas Real-time collaborative canvases with shapes, viewports, and undo/redo. The `useCanvas` hook gives you a complete collaborative canvas surface: add and move shapes, broadcast viewports across peers, undo and redo, all synchronized over a `CanvasRoom` Durable Object. Use it for whiteboards, mood boards, diagram tools, and any "shapes on an infinite plane" UI. ## Connect to a canvas ```tsx import { useCanvas } from 'deepspace' function Whiteboard({ canvasId }: { canvasId: string }) { const { shapes, viewports, connected, canWrite, addShape, moveShape, resizeShape, updateShape, deleteShape, setViewport, undo, redo, } = useCanvas(canvasId) // shapes is an array of CanvasShapeClient. // viewports is an array of ViewportClient - one entry per remote peer. } ``` `canvasId` is any string. Two clients passing the same `canvasId` connect to the same `CanvasRoom`. Use `canWrite` to disable draw / edit controls for viewers - shape mutations (`addShape`, `moveShape`, `resizeShape`, `updateShape`, `deleteShape`, `undo`, `redo`) **silently no-op when `canWrite` is `false`**, so unguarded buttons will appear to "click but do nothing" for read-only users. `setViewport` is exempt and stays open for viewers (viewport broadcasts are presence-like). ## Shape lifecycle A shape is an object with a position, size, and an arbitrary `props` payload. Each shape has: ```ts type CanvasShapeClient = { id: string type: string x: number y: number width: number height: number rotation?: number props: Record<string, unknown> createdBy: string createdAt: string updatedAt: string } ``` Add one with: ```tsx function onAddRect(x: number, y: number) { // addShape is synchronous and returns void. The server assigns the shape id // and broadcasts it back; let the next `shapes` render pick it up. addShape({ type: 'rect', x, y, width: 100, height: 60, props: { color: 'blue', label: 'New' }, }) } ``` The `type` is a free-form string - define your own vocabulary (`rect`, `circle`, `arrow`, `text`, `image`). The `props` field holds whatever metadata you need. Mutation methods are all positional and return `void`: ```ts moveShape(shapeId, 200, 150) // (shapeId, x, y) resizeShape(shapeId, 200, 120) // (shapeId, width, height, x?, y?) resizeShape(shapeId, 200, 120, 50, 80) // also reposition while resizing updateShape(shapeId, { color: 'red' }) // (shapeId, props) - props is merged deleteShape(shapeId) ``` ## Rendering shapes `shapes` is a flat array of `CanvasShapeClient` records. Render them however you like - SVG, canvas, DOM: ```tsx <svg width={800} height={600}> {shapes.map((s) => { if (s.type === 'rect') { return ( <rect key={s.id} x={s.x} y={s.y} width={s.width} height={s.height} fill={(s.props.color as string) ?? 'gray'} /> ) } if (s.type === 'circle') { return ( <circle key={s.id} cx={s.x} cy={s.y} r={s.width / 2} /> ) } return null })} </svg> ``` When any peer adds, moves, or deletes a shape, the `shapes` array re-renders. Note that `addShape` and `deleteShape` round-trip through the server, so the calling peer also gets the update from the broadcast. `moveShape`, `resizeShape`, and `updateShape` broadcast only to other peers - if you want the local peer to see the change immediately, update your UI optimistically. ## Viewports Each connected peer broadcasts a viewport (position, size, and zoom). Use it to show "where everyone is looking" in minimap-style UIs: ```tsx const { viewports, setViewport } = useCanvas(canvasId) function onPan(x: number, y: number, zoom: number, width: number, height: number) { // setViewport requires width and height (the visible viewport rect) in addition to x/y/zoom. setViewport({ x, y, zoom, width, height }) } // viewports is a ViewportClient[] - one entry per remote peer (your own // viewport is not included). Each entry includes `userId`. for (const vp of viewports) { console.log(`${vp.userId} is at (${vp.x}, ${vp.y}) zoomed ${vp.zoom}`) } ``` Viewports are ephemeral - they're not persisted. When a peer disconnects, their viewport disappears. ## Undo and redo Each peer has an independent undo stack: ```tsx const { undo, redo } = useCanvas(canvasId) <button onClick={undo}>Undo</button> <button onClick={redo}>Redo</button> ``` Undo reverses the calling peer's most recent edits only. It doesn't touch other users' actions. ## Worker setup The scaffold already wires `AppCanvasRoom`: ```ts // worker.ts export class AppCanvasRoom extends CanvasRoom<Env> {} const app = new Hono<{ Bindings: Env }>() app.get( '/ws/canvas/:docId', wsRoute((env) => env.CANVAS_ROOMS, () => ({ role: 'member' })), ) ``` You don't need to edit either side unless you're customizing shape persistence or adding server-side validation. ## A complete whiteboard ```tsx import { useState } from 'react' import { useCanvas } from 'deepspace' export default function Whiteboard({ canvasId }: { canvasId: string }) { const { shapes, addShape, moveShape, deleteShape } = useCanvas(canvasId) const [selected, setSelected] = useState<string | null>(null) function onCanvasClick(e: React.MouseEvent<SVGSVGElement>) { const rect = e.currentTarget.getBoundingClientRect() addShape({ type: 'rect', x: e.clientX - rect.left, y: e.clientY - rect.top, width: 80, height: 50, props: { color: '#1D4ED8' }, }) } return ( <svg width="100%" height="100vh" onClick={onCanvasClick}> {shapes.map((s) => ( <g key={s.id} onClick={(e) => { e.stopPropagation(); setSelected(s.id) }}> <rect x={s.x} y={s.y} width={s.width} height={s.height} fill={s.props.color as string} stroke={selected === s.id ? '#000' : 'none'} /> </g> ))} </svg> ) } ``` Open the page in two tabs. Click in each tab to add shapes; both surfaces stay in sync. ## When to use canvas vs Yjs Both can model "shapes that multiple users edit." Pick based on the merging needs: | Use canvas when… | Use Yjs when… | | ------------------------------------------- | -------------------------------------------- | | Shapes are independent units | Shapes form a unified document | | Last-write-wins per shape is fine | You need CRDT merging of overlapping edits | | You need a built-in undo stack | You need fine-grained operation-log merging | | Viewports / cursors are part of the surface | You want to use a third-party editor binding | Canvas is the right pick for whiteboards, mood boards, and most diagram tools. Yjs is the right pick when you're integrating Tiptap, ProseMirror, or a similar collaborative document editor. ## Next steps * [Collaborative editing](/guides/collaborative-editing) - Yjs-backed text and structured documents. * [Presence and cursors](/guides/presence-and-cursors) - live cursors with `usePresenceRoom`. * [Real-time sync](/concepts/realtime-sync) - how updates propagate. Source: /guides/canvas.md --- # File uploads Let users upload files (avatars, attachments, generated images) to R2. DeepSpace apps get a per-app R2 bucket out of the box. The `useR2Files` hook covers uploads, listings, deletions, and authenticated downloads - all proxied through the platform-worker so the app never holds R2 credentials directly. This guide shows the common end-to-end flows. For the full method signatures and types, see the [files reference](/sdk-reference/client/files). ## How files are wired You don't add an R2 binding yourself. The starter worker already proxies `/api/files/*` to the platform-worker, which holds a shared bucket and namespaces keys per app (via the `APP_NAME` the worker forwards on each request). Every write is gated by the caller's signed JWT. The client side is a single hook: ```ts import { useR2Files } from 'deepspace' ``` ## Upload from a file input The most common case - an `<input type="file">` or drag-drop event. Pass the resulting `File` to `upload`: ```tsx import { useR2Files } from 'deepspace' function FileUploader() { const { upload, isUploading } = useR2Files() async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0] if (!file) return const result = await upload(file, file.name) if (result.success) { console.log('uploaded:', result.key) } else { console.error(result.error) } } return ( <input type="file" onChange={onFileChange} disabled={isUploading} /> ) } ``` Always check `result.success` before reading `result.key` - the upload may fail (network, auth). `isUploading` is true while a request is in flight. ## Upload generated data (canvas, cropped image) When you have data as a Base64 string - for example, from `<canvas>.toDataURL()` - use `uploadBase64`. The display name is required so the file has an `originalName` for later downloads: ```tsx const { uploadBase64 } = useR2Files() async function saveCanvasAsImage(canvas: HTMLCanvasElement) { const dataUrl = canvas.toDataURL('image/png') const base64 = dataUrl.split(',')[1] const result = await uploadBase64(base64, 'drawing.png', 'image/png') if (!result.success) console.error(result.error) } ``` ## List and render a user's files `list()` is an async function - call it and store the result in state rather than reading a reactive array: ```tsx import { useState, useEffect } from 'react' import { useR2Files, formatFileSize } from 'deepspace' import type { R2FileInfo } from 'deepspace' function Gallery() { const { deleteFile, list, getUrl } = useR2Files() const [files, setFiles] = useState<R2FileInfo[]>([]) async function refresh() { setFiles(await list()) } useEffect(() => { refresh() }, []) return ( <div> {files.map((f) => ( <div key={f.key}> <img src={getUrl(f)} alt="" /> <p>{f.originalName ?? f.key} - {formatFileSize(f.size)}</p> <button onClick={async () => { await deleteFile(f); refresh() }}> Delete </button> </div> ))} </div> ) } ``` Re-call `list()` after mutations - there's no reactive cache. `formatFileSize` and `isImageFile` are display helpers exported from `deepspace`. ## Authenticated downloads `getUrl(fileOrKey)` returns a plain URL with no auth token attached. It works for unauthenticated reads on deployed sites (the platform-worker resolves the app from `APP_NAME` and serves reads without a JWT), which is what you want for `<img src>`. For everything else, use `downloadFile` or `readFile`: ```ts const { downloadFile, readFile } = useR2Files() // Trigger a Save As… dialog. Uses originalName as the filename automatically. const result = await downloadFile(file) if (!result.success) console.error(result.error) // Or read the bytes yourself - returns a Response you can .text(), .blob(), etc. const resp = await readFile(file) const text = await resp.text() ``` Both accept either an `R2FileInfo` from `list()` or a raw key string. ## Storing metadata (MIME type, captions, tags) `R2FileInfo` carries `key`, `size`, `uploaded`, `url`, `originalName`, and `uploadedBy` - and nothing else. There's no `mimeType` field. For richer metadata, store a sidecar record in a [collection](/concepts/data-model): ```ts // src/schemas/attachments-schema.ts import type { CollectionSchema } from 'deepspace/schema' export const attachmentsSchema: CollectionSchema = { name: 'attachments', columns: [ { name: 'fileKey', storage: 'text', interpretation: 'plain' }, { name: 'mimeType', storage: 'text', interpretation: 'plain' }, { name: 'caption', storage: 'text', interpretation: 'plain' }, ], permissions: { member: { read: true, create: true, update: 'own', delete: 'own' }, }, } ``` Create the sidecar alongside the upload. The snippet assumes a [`RecordProvider`](/sdk-reference/client/records) higher in the tree that has registered the `attachments` collection - `useMutations` throws otherwise. ```tsx import { useR2Files, useMutations } from 'deepspace' type Attachment = { fileKey: string; mimeType: string; caption: string } const { upload } = useR2Files() const { create } = useMutations<Attachment>('attachments') async function uploadWithMeta(file: File) { const result = await upload(file, file.name) if (!result.success || !result.key) { console.error(result.error) return } await create({ fileKey: result.key, mimeType: file.type, caption: '', }) } ``` ## Scoping and permissions `useR2Files` takes a scope, and the scope decides who can read the file: ```tsx const { upload } = useR2Files() // 'self' - per-user, auth-gated reads const { upload } = useR2Files({ scope: 'app' }) // app-wide, PUBLIC reads ``` | Scope | Prefix | Reads | | ------------------ | ----------------------------- | ------------------------------------------------- | | `'self'` (default) | `apps/<app>/users/<userId>/…` | Require the caller's auth token | | `'app'` | `apps/<app>/…` | Public - the URL works directly as an `<img src>` | Both scopes are per-app; the platform derives the prefix server-side, so a key can never address another app. For finer namespacing (per-room, per-project), encode it into the key at upload time or store it on a sidecar record. **`scope: 'app'` uploads are world-readable.** That is the point - it is what makes avatars and logos embeddable - but never put private data there. For private files use `'self'` and read them with `readFile` / `downloadFile`, which send the Authorization header, rather than rendering `getUrl()`. Writes always require a signed-in user, under either scope. There is no recycle bin - `deleteFile` is immediate and irreversible. ## Large files and media Two different ceilings apply depending on how an asset reaches production. ### File size and storage limits A file can be up to **1 GiB**. One HTTP request carries at most **25 MiB**, so both `useR2Files` and `deepspace app files put` automatically upload anything above **20 MiB** in parts — no flag, no size math on your side. The server also refuses content types it would execute as active content (HTML, SVG, JS). The refusal is based on the **declared type** (from the file extension or the type your code supplies), so renaming a file can get its bytes stored — but not executed: every stored file is served with its stored `Content-Type` and `X-Content-Type-Options: nosniff`, so browsers never sniff a disguised file into running in your app's origin. The serving headers are the guarantee; the upload refusal is the early warning. **Your app's whole file allocation is capped by the owner's plan** — 128 MiB on the free plan, more on paid ones. The limit binds wherever bytes are written: your app's own uploads at runtime and your `deepspace app files put` from the CLI spend the same allocation, always against the *owner's* plan whoever is uploading. An upload that would cross it is refused with `storage_quota_exceeded` (HTTP 409) naming what is used and what the limit is, and nothing partial is stored. Replacing a file at an existing key is only charged for the bytes it adds, so swapping a large file for a smaller one always works even when you are at the ceiling. ```bash npx deepspace app files list # KEY SIZE UPLOADED # hero.jpg 2.4 MiB 2026-08-06 # Storage: 2.4 MiB of 128.0 MiB used ``` Deploy assets are a **separate** allocation — a full file allocation never blocks a deploy, and vice versa. ### Publishing assets as the owner: `deepspace app files` If *you* (not an end user) need to publish an image or a media file, you can push it straight into the app's files allocation from the command line - no deploy, no commit: ```bash npx deepspace app files put logo.png npx deepspace app files put hero.jpg --key img/hero.jpg npx deepspace app files list --prefix img/ npx deepspace app files get img/hero.jpg --out ./hero.jpg npx deepspace app files rm img/hero.jpg ``` Keys you pass are relative to the app (`logo.png`). The SERVING URL is not: it carries the physical prefix the platform owns, so it is `/api/files/apps/<resourceId>/<key>?scope=app`. Requesting `/api/files/logo.png?scope=app` does not resolve. Do not assemble it by hand — `deepspace app files put` prints the exact URL, and returns it as `path` under `--json`: ``` ✓ logo.png (18.2 KiB) Served from your app at /api/files/apps/app_01H…/logo.png?scope=app ``` This reaches the same app-scoped storage as `useR2Files({ scope: 'app' })`, but as the app **owner** rather than as an end user. ### Don't commit large media to Git The cloud repo enforces **20 MiB per object and 32 MiB of compressed history per push**. An oversized push is refused with `push_too_large`, and `deploy` reports the same rejection. **Untracking the file does not fix an oversized push.** `git rm --cached` plus a `.gitignore` entry changes your worktree, but the blob stays reachable from the commit that introduced it - so the next push sends identical bytes and is refused identically. You must remove the file from the commits that actually carry it, or rewrite history if it was already pushed. Find the offending object with: ```bash git rev-list --objects --all \ | git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' \ | sort -k2 -n | tail ``` Then put the media in `deepspace app files` instead - that is what the surface exists for. ## Local development R2 uploads require an `APP_IDENTITY_TOKEN` minted by the deploy worker. Until the app has been deployed at least once, the CLI can't fetch one, so `upload()` round-trips return 401 from the platform file gateway. After a first deploy, `npx deepspace dev start` provisions the token into `.dev.vars` and uploads work locally. For tests written before the first deploy, assert that uploads are *dispatched* (the function is called) rather than asserting on the round-trip. ## Next steps * [Files reference](/sdk-reference/client/files) - full method signatures, return types, and the `R2FileInfo` shape. * [Command reference: `app files`](/cli-reference/commands#app-files) - the owner-side CLI surface. * [Custom bindings](/guides/custom-bindings) - declare a wholly separate R2 bucket with custom permissions. * [Data model](/concepts/data-model) - pair files with sidecar records for queryable metadata. Source: /guides/file-uploads.md --- # AI chat Streamed multi-turn chat with Claude, GPT, and Cerebras - tool use, persistent history, and context compaction included. Stream multi-turn chat with persistent history and built-in tool use over your records. The scaffold registers four HTTP endpoints, persists every chat to a Durable Object, and streams responses via [Vercel AI SDK v5](https://ai-sdk.dev/). Use the bundled `ChatPanel` component for a turnkey UI, or call the streaming endpoint directly and decode it with the [wire helpers](#custom-chat-ui). ## Install the chat feature Install the bundled feature: ```bash npx deepspace add ai-chat ``` **Copilot-template apps already ship this chat surface.** The same `ChatPanel` lives at `src/components/chat/ChatPanel.tsx`, embedded in the shell's chat dock (`src/components/shell/ChatDock.tsx`). Don't run `add ai-chat` there — the installer doesn't detect the template's copy at that path, so it would install a second, divergent panel against the same collections. `add ai-chat` is for adding the full-page assistant to starter-template apps. This installs five files: * `src/components/ChatPanel.tsx` - the chat surface composing the message list, model picker, composer, and abort/retry controls. * `src/components/ChatPanel.messages.tsx` - memoized message, Markdown, tool-status, empty, and thinking renderers. * `src/components/ChatPanel.stream.ts` - the stream transport and pending-overlay reducer; owns auto-create, abort, retry, SSE errors, and the tool-call lifecycle. * `src/pages/(app)/(protected)/assistant.tsx` - a protected full-page assistant with a chat history rail. * `src/schemas/ai-chat-schema.ts` - exports `aiChatSchemas` (an array of `[AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA]`) for spreading into `src/schemas.ts`. The feature also adds `react-markdown`, `remark-gfm`, `remark-breaks`, `rehype-highlight`, and `highlight.js` to `package.json`. Pass `--install` to have the installer run your package manager, or run `npm install` yourself before building or deploying - the build fails on the missing packages otherwise. ## Embedding `ChatPanel` `ChatPanel` is designed to be embedded - a sidebar, a modal, a dock - not only used on the assistant page. The parent owns the chat lifecycle: * Pass `chatId={null}` to auto-create a chat on first send, and `onChatCreated` to capture the new id. `onChatCreated` is required in practice whenever `chatId` starts null - without it the parent never learns which chat the panel created. * Pass `disabled` while a parent-owned create is in flight so the panel doesn't kick off its own duplicate auto-create. ```tsx <ChatPanel chatId={activeChatId} onChatCreated={(id) => setActiveChatId(id)} disabled={isCreatingChat} /> ``` Persisted messages arrive live through `useQuery('ai-messages')`; the stream hook overlays the pending user and assistant turns immediately, then removes each overlay entry as the matching persisted `recordId` arrives. The `X-Asst-Id` response header ties the pending assistant to its eventual row without relying on client clocks. The overlay rules: * **Stop or a transport failure** removes an empty pending assistant but preserves partial text and tool rows the user has already seen. * **Switching away from a real chat** aborts the in-flight turn and clears its overlay. The `null → id` transition during auto-create is not a switch and does not abort. * **Top-level failures** render an alert with a **Retry** button that re-sends the last content. Tool input/output failures stay inline on their tool row and don't tear down the turn. ## Add the schemas If you're wiring chat by hand, import the two pre-built schemas directly: ```ts // src/schemas.ts import { AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA } from 'deepspace/schema' export const schemas = [ usersSchema, settingsSchema, AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA, // ...your collections ] ``` Or, if you ran `npx deepspace add ai-chat`, spread the array the feature installed: ```ts // src/schemas.ts import { aiChatSchemas } from './schemas/ai-chat-schema' export const schemas = [usersSchema, settingsSchema, ...aiChatSchemas] ``` | Schema | Rows | RBAC | | ------------------------------------ | ----------------------------------- | -------------------------------------------- | | `AI_CHATS_SCHEMA` (`ai-chats`) | One per chat conversation | `read/update/delete: 'own'`, `create: false` | | `AI_MESSAGES_SCHEMA` (`ai-messages`) | One per message (user or assistant) | `read/update/delete: 'own'`, `create: false` | **`create: false` is intentional.** Direct WebSocket creates would let a user PUT a forged `role: 'assistant'` row that the next turn's history reads back as if it were a real LLM response. Don't relax to `'own'`. All writes flow through the worker's chat routes, which validate ownership. ## The four chat endpoints The scaffold defines four endpoints in `src/ai/chat-routes.ts` and registers them in `worker.ts` with `registerAiChatRoutes(app, resolveAuth)`. The first three manage chat records; the fourth streams a turn. Create chat ```http POST /api/ai/chats Authorization: Bearer <jwt> Content-Type: application/json { "title": "Untitled" } ``` Returns: ```json { "chat": { "recordId": "chat_abc", "userId": "...", "title": "Untitled", ... } } ``` Creates a chat row owned by the JWT subject. The `title` field is optional. Rename chat ```http PATCH /api/ai/chats/:id Authorization: Bearer <jwt> Content-Type: application/json { "title": "Renamed" } ``` Owner-checked. Returns 404 if the chat doesn't exist or belongs to another user. Delete chat ```http DELETE /api/ai/chats/:id Authorization: Bearer <jwt> ``` Deletes the chat row and cascade-deletes its `ai-messages` rows. Owner-checked. Stream a turn ```http POST /api/ai/chat Authorization: Bearer <jwt> Content-Type: application/json { "chatId": "chat_abc", "userMessageId": "umsg_xyz", "content": "Hello", "modelId": "claude-sonnet-5" } ``` Returns `text/event-stream` of Vercel AI SDK v5 `UIMessageChunk` events. The `X-Asst-Id` response header carries the assistant row's ID for client-side dedup. For decoding the stream in custom UIs, see [Custom chat UI](#custom-chat-ui). ## Streaming pipeline The `POST /api/ai/chat` handler runs through these steps: Verify the JWT Reject anonymous callers with 401. Look up the chat Return 404 if the chat doesn't exist or belongs to another user. Load history without persisting the new message The new user turn is appended in memory only; persistence starts inside `onFinish`, so a transport failure or zero-step abort before then writes nothing. Consecutive user messages are deduplicated as defense in depth against malformed history. Prepare messages with compaction Truncate old tool results, apply a cached summary if one exists, and summarize the older half of the conversation if still over the context budget. Stream the model Call `streamText` with the prepared messages, tools, and an abort signal tied to the request. Persist on completion Write user → assistant → metadata rows in that order, with per-write retry. If the user write fails twice, the assistant write is skipped - an assistant row must never exist without its preceding user row. The reverse remains possible: the assistant write can still fail after the user write succeeds, so retries reduce but don't eliminate a half-persisted turn. Don't reorder the writes. ## Switch the model The catalog lives in the SDK, not in your app. `src/ai/chat-routes.ts` resolves whatever the client asked for against it: ```ts import { resolveDeepSpaceAgentModel } from 'deepspace/worker' const selectedModel = resolveDeepSpaceAgentModel(modelId, 'application') if (!selectedModel) return c.json({ error: 'unknown_model' }, 400) ``` Unknown or non-agent `modelId` values resolve to `null` and are rejected with 400 - there is no silent fallback. Omitting `modelId` selects the profile's default. **Do not copy the catalog into your app.** An allowlist in app code is a second source of truth that goes stale the day a model is added or renamed, and it cannot be fixed by upgrading the SDK. Resolve against the SDK instead. To render a picker, ask the SDK what the profile supports rather than hardcoding options - this is what the scaffold's `ChatPanel` does: ```ts import { listDeepSpaceAgentModels } from 'deepspace/worker' const models = listDeepSpaceAgentModels('application') // default model sorts first ``` ### Agent-capable models Every model below supports multi-step tool use, which is what the `application` profile requires. `claude-sonnet-5` is the default. | Model ID | Provider | Family | | --------------------------- | --------- | ---------- | | `claude-sonnet-5` (default) | Anthropic | Claude 5 | | `claude-fable-5` | Anthropic | Claude 5 | | `claude-opus-5` | Anthropic | Claude 5 | | `claude-haiku-4-5` | Anthropic | Claude 4.5 | | `gpt-5.6-sol` | OpenAI | GPT-5.6 | | `gpt-5.6-terra` | OpenAI | GPT-5.6 | | `gpt-5.6-luna` | OpenAI | GPT-5.6 | `gpt-oss-120b` (Cerebras) is available to `createDeepSpaceAI` but is **single-step** - it is deliberately absent from the agent profiles, so `resolveDeepSpaceAgentModel` rejects it for chat. This table is checked against the SDK's own catalog by a unit test (`src/lib/docs-model-catalog.test.ts`). If a model is added, renamed, or drops agent support, that test fails rather than this page quietly going stale - which is exactly how it came to list five model IDs that did not exist. Provider routing happens via `createDeepSpaceAI`: ```ts import { createDeepSpaceAI } from 'deepspace/worker' const provider = createDeepSpaceAI(env, 'anthropic', { authToken: jwt }) ``` | `authToken` | Who pays | | ----------- | -------------------------------------------------------------------- | | Passed | The caller (signed-in user) - billed against their DeepSpace credits | | Omitted | The app owner - billed via `APP_OWNER_JWT` | The scaffold's chat routes pass the caller's JWT, so each user pays for their own conversation. Omit `authToken` for autonomous server-side calls (cron, server actions). ## Tool use The assistant can read and modify your records via a built-in tool catalog. The scaffold ships all of them in `src/ai/tools.ts`: | Tool | Purpose | | ----------------- | ------------------------------------------------- | | `schema.list` | Enumerate collection names | | `schema.describe` | Describe one collection's columns and permissions | | `records.query` | Filter and list records | | `records.get` | Fetch one record | | `records.create` | Create a record | | `records.update` | Patch a record | | `records.delete` | Delete a record | | `user.current` | Look up the caller's user record | **Per-collection RBAC at the DO is the security boundary.** The user's own role determines what each tool call can do - the assistant cannot escalate. To run a stricter assistant, trim `ALLOWED_TOOL_NAMES` to reads only. ### The system prompt `buildSystemPrompt(appName, schemas)` in `src/ai/tools.ts` produces a concise prompt that lists every collection with its columns, marking required columns with `!`. It ships with mutation guardrails - keep them (or equivalents) when you customize: * Confirm intent before destructive actions (delete, bulk update). * If a write is denied by RBAC, tell the user plainly - do not retry blindly. Customize by editing `buildSystemPrompt` directly; it's app code, not SDK code. ### Adding custom tools Extend the `ToolSet` returned by `buildTools` in `src/ai/tools.ts`: ```ts // src/ai/tools.ts import { tool, type ToolSet } from 'ai' import { z } from 'zod' import { BUILT_IN_TOOLS } from 'deepspace/worker' export function buildTools(executor: ToolExecutor): ToolSet { const tools: ToolSet = {} // ...existing loop over BUILT_IN_TOOLS... tools.lookup_weather = tool({ description: 'Get weather for a city', inputSchema: z.object({ city: z.string() }), execute: async ({ city }) => { const res = await fetch(`https://api.example.com/weather?city=${encodeURIComponent(city)}`) if (!res.ok) return { error: `weather lookup failed: ${res.status}` } return await res.json() }, }) return tools } ``` The Zod `inputSchema` doubles as runtime validation; failing input emits a `tool-input-error` SSE chunk the client surfaces. ## Context compaction For long conversations, the scaffold automatically compacts older turns to stay under the model's context budget. The default config exported from `deepspace/worker`: ```ts import { DEFAULT_CONTEXT_CONFIG } from 'deepspace/worker' // { // contextBudget: 240_000, // chars - ≈60–80K tokens // toolResultCap: 30_000, // bytes per tool result // keepRecentToolResults: 5, // minKept: 10, // sliding-window floor // } ``` Tune for shorter-context models by passing your own config to `prepareMessagesWithCompaction` in `chat-routes.ts`: ```ts import { DEFAULT_CONTEXT_CONFIG, prepareMessagesWithCompaction } from 'deepspace/worker' const config = { ...DEFAULT_CONTEXT_CONFIG, contextBudget: 120_000, // for 128K-context models // contextBudget: 40_000, // for 32K-class models (some Cerebras open-weights) } const { messages: prepared, newSummary } = await prepareMessagesWithCompaction( turns, config, { summarizer, cachedSummary }, ) ``` The pipeline: 1. Truncate old tool-result payloads (preserves the most recent N intact). 2. Apply a cached summary if one exists. 3. If still over budget, summarize the older half of the conversation. 4. As a final fallback, apply a sliding window down to `minKept` messages. ## Custom chat UI If you want to build your own chat surface (sidebar, modal, minimal), call `POST /api/ai/chat` directly and decode the SSE stream with the SDK's wire helpers: ```tsx import { parseSseLine, decodeAiStreamChunk, getAuthToken, type AiStreamAction } from 'deepspace' async function streamTurn(chatId: string, content: string, handleAction: (asstId: string, action: AiStreamAction) => void) { const token = await getAuthToken() const res = await fetch('/api/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ chatId, userMessageId: crypto.randomUUID(), content }), }) const asstId = res.headers.get('X-Asst-Id')! const reader = res.body!.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() ?? '' for (const line of lines) { const chunk = parseSseLine(line) if (!chunk) continue const action = decodeAiStreamChunk(chunk) if (action) handleAction(asstId, action) } } } ``` ### Action vocabulary `decodeAiStreamChunk` returns one of: | Action | When it fires | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `append-text` | Text-delta token from the model | | `upsert-tool-call` | A tool invocation started | | `finalize-tool-call` | A tool returned its result | | `fail-tool-input` | The tool's Zod schema rejected the input. **No preceding `upsert-tool-call` was emitted** - your reducer must create the invocation and finalize it as failed in one step | | `fail-tool-output` | The tool's `execute` threw; a previous `upsert-tool-call` exists to finalize as failed | | `stream-error` | Top-level stream error | | `abort` | Server-side abort with no error chunk to follow | For the canonical message list, query `ai-messages` from inside a [`RecordScope`](/sdk-reference/client/records#recordscope): ```tsx import { useQuery } from 'deepspace' type AiMessageData = { chatId: string userId: string role: 'user' | 'assistant' content: string parts?: unknown[] } const { records } = useQuery<AiMessageData>('ai-messages', { where: { chatId, userId }, orderBy: 'createdAt', orderDir: 'asc', }) // Each record is { recordId, data, createdAt, updatedAt } - fields live on `.data`. records.map((r) => ({ id: r.recordId, role: r.data.role, content: r.data.content })) ``` The `parts` field on each `data` holds UI-shape tool invocations for rendering. ## Testing the chat endpoints Test the streaming endpoint from `api.spec.ts`: ```ts test('POST /api/ai/chat streams and persists', async ({ request }) => { // Pre-condition: signed-in user token, an existing chat row. const chatRes = await request.post('/api/ai/chats', { headers: { Authorization: `Bearer ${token}` }, data: { title: 'test' }, }) const { chat } = await chatRes.json() const res = await request.post('/api/ai/chat', { headers: { Authorization: `Bearer ${token}` }, data: { chatId: chat.recordId, userMessageId: `umsg-${Date.now()}`, content: 'Hi' }, }) expect(res.status()).toBe(200) expect(res.headers()['x-asst-id']).toMatch(/^asst-/) // Drain the stream, then assert (via the UI or a follow-up query) that two // new ai-messages rows exist - one user, one assistant. }) ``` The rules that keep this suite honest: * **One test per turn-shape** - text-only, tool-using, multi-step, abort. Not one test per chunk type. * **Assert behavior, not parser fidelity.** The SDK already unit-tests `decodeAiStreamChunk` against the chunk vocabulary; app-level tests should assert that rows persist and UI state lands, not re-verify the wire format. * **Cover the auth gates**: 401 for unauthenticated callers, 404 for a `chatId` owned by another user. See the [testing guide](/guides/testing) for the standard negative-path pattern. ## Limitations Concurrent multi-tab writes can interleave The Durable Object serializes individual writes, but not the per-request 3-write group (user → assistant → metadata). Two tabs sending turns simultaneously to the same `chatId` can produce a non-strictly-paired history. Realistic impact is rare. \`stopWhen: stepCountIs(5)\` caps tool loops Each turn can chain up to 5 tool calls. Raise this in `chat-routes.ts` for agentic workflows that need more steps - each step is a full LLM round-trip with proportional cost. Reasoning content is stripped `toUIMessageStreamResponse({ sendReasoning: false })` removes `reasoning-*` chunks. The default UI has no "thinking" disclosure block, so flipping this on without UI changes shows no progress indication during long reasoning steps. \`X-Asst-Id\` header is required for dedup The header lets the client tag in-flight overlays with a server-generated ID that survives clock skew. If you proxy the streaming response through another worker, preserve the header. ## Next steps * [Worker AI reference](/sdk-reference/worker/ai) - `createDeepSpaceAI`, compaction helpers, chat-history wrappers. * [Server actions](/guides/server-actions) - privileged routes that bypass user RBAC. * [External APIs](/guides/external-apis) - call LLMs and other services through `integration.post`. Source: /guides/ai-chat.md --- # Payments Charge users on a subscription or one-time basis with Stripe Checkout. DeepSpace ships a Stripe-backed payment system. You declare your plans and products in two manifest files; the SDK gives you hooks for paywalls, checkouts, and self-service billing. The platform charges customers and routes funds to your connected Stripe account. **Do not install Stripe libraries.** Declare the catalog in the manifest files and use the SDK hooks and server helpers - that is the entire integration surface. You don't write Stripe code, register webhooks, or hold API keys. You don't even need a Stripe account to start declaring plans - but paid checkout stays unavailable until you complete Stripe Connect onboarding in the [DeepSpace dashboard](https://dashboard.deep.space/earnings): until then, checkout attempts fail with the typed `owner_connect_not_ready` error, and `deepspace deploy` warns that paid checkout is not yet available. ## Declare what you sell Two manifest files define the catalog. Edit, then run `npx deepspace deploy` to sync Products and Prices to Stripe. ### Subscription plans - `src/subscriptions.ts` ```ts export const subscriptionPlans = [ { slug: 'free', name: 'Free', priceCents: 0 }, { slug: 'pro', name: 'Pro', priceCents: 900, // $9/month - minimum $3/mo yearlyCents: 9000, // optional; minimum $12/year trialDays: 7, // optional; max 365 days taxCode: 'txcd_10000000', // optional; defaults to digital services }, ] as const ``` **One `taxCode` per plan.** The tax code defaults to `txcd_10000000` (digital services) and applies to the whole plan - the monthly and annual prices share it. If monthly and annual offerings need different tax treatment, declare them as separate plans. ### One-time products - `src/products.ts` ```ts export const oneTimeProducts = [ { productId: 'pro_unlock', name: 'Pro Unlock', amountCents: 1999, description: '...' }, ] as const ``` Removing a row from `oneTimeProducts` **deactivates** the product on the next deploy - it can no longer be purchased, but it is not deleted: historical invoices still reference it, and existing purchases stay valid and keep gating features. See the [plan manifest types](/sdk-reference/client/payments#plan-manifest-types) for the full field list. ```bash npx deepspace deploy ``` The CLI warns about grandfathered subscribers when you change prices. ## Subscribe a user Call `subscribe()` from [`useSubscription`](/sdk-reference/client/payments#usesubscription). The hook navigates the browser to Stripe Checkout; the user lands back on your app with their subscription active. ```tsx import { useSubscription } from 'deepspace' function Paywall() { const sub = useSubscription() if (sub.isLoading) return null if (sub.isAtLeast('pro')) return <ProUI /> return <button onClick={() => sub.subscribe('pro')}>Upgrade</button> } ``` For a ready-made pricing UI, mount [`<PricingTable />`](/sdk-reference/client/payments#pricingtable-plans-onselect) and wire its `onSelect` to `subscribe`: ```tsx import { PricingTable, useSubscription } from 'deepspace' function Pricing() { const { plans, tier, subscribe } = useSubscription() return ( <PricingTable plans={plans} currentTier={tier} onSelect={(slug, interval) => subscribe(slug, { interval })} /> ) } ``` `useSubscription` also exposes `openPortal()` for self-service billing - point a "Manage billing" button at it. **Gate features on `hasTier` / `isAtLeast`, never on `tier` alone.** A user whose card just failed has `tier: 'pro'` and `status: 'past_due'` - they keep the slug but lose entitlement. `sub.tier === 'pro'` leaks paid features to past-due, canceled, and unpaid users. ## Gate a server route The client checks are for UX only - anyone can call your API directly. Gate sensitive routes with [`requireSubscription`](/sdk-reference/client/payments#requiresubscription-c-opts) from `'deepspace/server'`: ```ts // worker.ts import { requireSubscription, SubscriptionAuthError, SubscriptionRequiredError } from 'deepspace/server' app.get('/api/premium', async (c) => { try { await requireSubscription(c, { atLeast: 'pro' }) } catch (e) { if (e instanceof SubscriptionAuthError) return c.json({ error: 'unauthenticated' }, 401) if (e instanceof SubscriptionRequiredError) return c.json({ error: 'upgrade_required', required: e.required }, 402) throw e } // Protected logic here }) ``` The browser must attach the JWT to every gated request: ```ts import { getAuthToken } from 'deepspace' const r = await fetch('/api/premium', { headers: { Authorization: `Bearer ${await getAuthToken()}` }, }) ``` Use [`getSubscription`](/sdk-reference/client/payments#getsubscription-c) for the read-only variant that returns the subscription object without throwing. ## One-time charges [`useCheckout`](/sdk-reference/client/payments#usecheckout-productid) handles non-recurring purchases in two modes - **product mode** for durable entitlements declared in `src/products.ts`, and **ad-hoc mode** for tips and donations. Product mode Pass the same `productId` to the hook and to `chargeOnce`. The hook exposes `owned` so you can gate UI before the user pays. ```tsx import { useCheckout } from 'deepspace' function ProUnlock() { const co = useCheckout({ productId: 'pro_unlock' }) if (co.owned) return <ProUI /> return ( <button onClick={() => co.chargeOnce({ productId: 'pro_unlock' })}> Buy </button> ) } ``` Product entitlements survive across sessions and devices - the platform tracks them per user. `owned` is `true` only while a matching **non-refunded** purchase exists: a full refund revokes the entitlement, while a partial refund keeps it (the customer paid for something they didn't fully unwind). Ad-hoc mode For tips, donations, and "name your price" surfaces. The caller picks the amount. ```tsx const co = useCheckout() await co.chargeOnce({ amount: 500, name: 'Tip', description: 'Thanks!', }) ``` **Ad-hoc charges can't be used to unlock features later.** Ad-hoc purchases have `productId: null`, so `ownsProduct(id)` will never return true for them. Use ad-hoc mode only when the transaction itself is the value. For durable entitlements, declare a row in `src/products.ts`. ## Cancel a subscription [`cancelSubscription`](/sdk-reference/client/payments#cancelsubscription-c-opts) cancels one user, or every user on a given plan slug. The inbound request must carry the app-owner's JWT. ```ts import { cancelSubscription } from 'deepspace/server' app.post('/api/admin/cancel', async (c) => { // One user, end of current period (default): await cancelSubscription(c, { userId: 'user_abc' }) return c.json({ ok: true }) }) // Or, for a one-off backfill - every user on a retired plan, batched 50 at a time: // let res = await cancelSubscription(c, { planSlug: 'legacy_pro' }) // while (res.hasMore) res = await cancelSubscription(c, { planSlug: 'legacy_pro' }) ``` Pass `atPeriodEnd: false` for immediate cancellation. ## Issue a refund [`refundInvoice`](/sdk-reference/client/payments#refundinvoice-c-opts) refunds a charge by its local invoice ID. Wrap it behind your own admin check. ```ts import { refundInvoice } from 'deepspace/server' app.post('/api/admin/refund', async (c) => { // Your admin check goes here. const { invoiceId } = await c.req.json<{ invoiceId: string }>() const r = await refundInvoice(c, { invoiceId, // local UUID, NOT stripe inv_xxx amount: 500, // optional partial in cents reason: 'requested_by_customer', }) return c.json(r) }) ``` Constraints: 90-day refund window from `paidAt`, 50 refunds per 24h per app, no overdraw on partials. Dashboard-initiated refunds reconcile automatically. Both `refundInvoice` and `cancelSubscription` forward the caller's JWT; the platform rejects anyone but the app owner with a 403 and the error code `not_app_owner`. That platform check protects the money path - your own admin gate on the route protects everything else the route does, so keep both. ## Common pitfalls Tier ≠ entitled A `past_due` subscriber keeps their tier slug but loses access. Always gate on `hasTier()` / `isAtLeast()` on the client, and `requireSubscription` on the server - all three check status, not just slug. \`currentPeriodEnd\` is Unix milliseconds `currentPeriodEnd` and `trialEndsAt` are Unix **milliseconds**. Pass them straight to `new Date()` - no multiplication needed. Below-minimum prices fail at deploy Subscription minimums are $3/month and $12/year. One-time minimum is $1.00. Below these, Stripe's per-charge fee consumes the entire price. The deploy worker rejects the manifest before syncing to Stripe. Never rename a plan's slug The slug is the stable identifier for existing subscribers, server-side gates, and the underlying Stripe Product. Renaming on deploy is interpreted as "delete + create" - existing subscribers stay billed on the orphaned price. For branding changes, edit `name` instead. Local state lags Checkout by 1–2 seconds The Stripe webhook fires shortly after the user returns from Checkout. Call `sub.refresh()` / `co.refresh()` once on return. If state is still stale, refresh again on user action - don't write a tight retry loop. Connect onboarding lives in the dashboard, not your app Developer Stripe Connect onboarding happens at [dashboard.deep.space/earnings](https://dashboard.deep.space/earnings), outside your app. Don't build any Stripe Connect UI yourself. Paid checkout is blocked until onboarding completes - the platform requires an account that can accept both charges and payouts before it will start a checkout session, so there is no state where sales accrue ahead of onboarding. ## Next steps * [Payments reference](/sdk-reference/client/payments) - full prop, option, and return-shape tables. * [Authentication](/guides/authentication) - gate UI behind sign-in. * [Server actions](/guides/server-actions) - admin-only routes for cancellations and refunds. Source: /guides/payments.md --- # Documentation Publish a documentation site from MDX files in your repo — with search, an AI assistant, and an MCP endpoint. Point the feature at a folder of MDX and it serves a documentation site: navigation, full-text search, an AI assistant that can read your pages, and an MCP endpoint so agents can query them directly. The site you are reading is built with it. Documentation is part of the app's ordinary source tree, build, worker, and release. There is no separate docs app, no separate runtime, and no separate deployment pipeline. ## Install ```bash npx deepspace add --info documentation # inspect the feature first npx deepspace add documentation ``` The installer creates `documentation.json` and starter pages under `documentation/`, wires the Vite plugin and the worker route shown below, adds the feature's rate-limit bindings to `wrangler.toml`, and gitignores the generated `public/_documentation` output. If a wired file has been customized past the insertion markers, the installer refuses with exact manual steps instead of overwriting. From there, use the ordinary lifecycle - `npx deepspace dev start`, `npx deepspace test run`, `npx deepspace deploy`. **There is no `deepspace docs` command group**; don't invent commands for it. ## What you write Two things — a config file and a folder of MDX. ``` documentation.json # name, domains, navigation documentation/ index.mdx guides/ getting-started.mdx ``` Every page is Markdown or MDX with frontmatter: ```mdx --- title: "File uploads" description: "Let users upload files to R2." --- Body content. Standard Markdown, plus the built-in components below. ``` Markdown is the inert default - prefer it. Reach for MDX only when trusted, same-repository React is genuinely useful on a page; MDX may import your app's components directly, so it carries your app's trust level. There is no plugin registry - local components and CSS are just app code. ## Wire it up Two lines, and `add documentation` inserts both for you. The Vite plugin compiles the corpus at build time; the worker route serves it. ```ts // vite.config.ts import { deepSpaceDocumentation } from 'deepspace/documentation' export default defineConfig({ plugins: [deepSpaceDocumentation(), react()], }) ``` ```ts // worker.ts import { registerDeepSpaceDocumentation } from 'deepspace/worker' import documentationConfig from './documentation.json' registerDeepSpaceDocumentation(app, { resolveAuth, config: documentationConfig }) ``` **Order matters.** Register it *after* your own `/api` routes — so auth, actions, and AI keep working on the documentation hostname — and *before* the platform proxy and the static/SPA fallback. That is the order the SDK expects. ## Configure `documentation.json` is the single configuration surface. Beyond identity and navigation it owns external links, redirects, assistant and MCP access (and their billing mode), contextual actions, SEO, and OpenAPI inputs. The native shape: ```json { "name": "DeepSpace Documentation", "description": "SDK reference and developer documentation.", "domains": ["docs.deep.space"], "theme": { "accent": "#635BFF", "logo": "/logo/light.svg", "logoDark": "/logo/dark.svg" }, "navigation": [ { "group": "Get started", "pages": ["index", "quickstart"] } ], "links": [{ "label": "GitHub", "href": "https://github.com/example/repo" }], "redirects": { "/old-path": "/new-path" }, "assistant": { "access": "authenticated" }, "mcp": { "access": "public" }, "seo": { "noindex": false }, "openapi": [{ "source": "openapi.json", "playground": true }] } ``` The installed `DocumentationConfig` types are the authority on every key and its exact shape - inspect them (and the feature's starter config) before adding keys rather than guessing from examples. The build also accepts the Mintlify-compatible dialect - `colors` instead of `theme.accent`, `logo: { "light", "dark" }`, `navigation.groups` instead of the flat `navigation` array, `navbar`, `footer.socials` - and normalizes it into the native shape at build time, warning on any key it ignores. This site's own `documentation.json` is written in that dialect. Whichever dialect you write, it is still one file and one authority. **Migrating from Mintlify: `documentation.json` becomes the only authority.** Do not keep a `docs.json` around, and do not carry Mintlify aliases or config conventions as a second source of truth - port what you need into `documentation.json` and delete the rest. Two configs describing one site always diverge. ### Navigation is validated at build time `navigation` entries are paths under `documentation/` without the extension. When you declare explicit navigation, it must contain **every public page**: * A public page missing from navigation is a **build error** - `N public page(s) are missing from navigation` - not a hidden-but-reachable page. To keep a page out of the sidebar deliberately, set `hidden: true` in its frontmatter. Only omissions are policed: a page listed in more than one place is accepted without complaint, so keeping the sidebar duplicate-free is on you. * A navigation entry pointing at a page or internal route that doesn't exist is equally a build error, as are broken internal links between pages, redirect loops, a redirect source that shadows a real page, and two source files resolving to the same route. Validation is not total, though - a few behaviors stay silent: duplicate navigation entries (above), duplicated `redirects` keys (`redirects` is a JSON object, so the same source route listed twice is last-write-wins at parse time, not an error), and link fragments (the `#anchor` part is stripped before link validation, so a link to an existing page with a wrong anchor passes). Treat every one of these as a defect in the corpus and fix it. Don't restructure config to sneak past validation - the checks exist so readers never hit a dead sidebar entry or an unlisted orphan. ### Where it serves | Config | URL | | ----------------------- | ---------------------------------------------------------- | | Always | `/docs` on the app's own origin | | Each entry in `domains` | that hostname's root — `documentation.deep.space/guides/…` | The `domains` form is how a documentation site gets its own hostname while living inside an ordinary DeepSpace app. Attach the hostname to the app first (see [custom domains](/guides/custom-domains)); listing it here only tells the feature to serve the corpus at that host's root. ## What you get for free **Search** across the corpus, built at compile time — no external index, no API key. **An AI assistant** with two read-only tools, `documentation_search` and `documentation_read`. It runs on the `documentation` agent profile, which is capped at 20 tool calls and cannot reach your app's data — the tool list is fixed by the SDK, not by the page. **An MCP endpoint**, so an agent can query your documentation the same way it queries any other MCP server. ### Machine surfaces Depending on config, the feature also emits a family of machine-readable surfaces: a per-page Markdown rendering of every page, `/docs/llms.txt` and `/docs/llms-full.txt`, `/docs/skill.md`, `sitemap.xml`, `robots.txt`, generated OpenAPI reference pages, the MCP endpoint (`/docs/mcp`, discoverable at `/docs/.well-known/mcp`), and the per-page contextual actions (copy as Markdown, open in assistant, and so on). Consume these through the generated manifest or the runtime routes. Do not infer what is enabled by poking at output directories — the manifest is the contract, the directory layout is an implementation detail. ### Assistant access and billing `assistant.access` and `mcp.access` are explicit choices — make them deliberately: | `assistant.access` | Who can ask | Who pays | | ------------------ | ---------------------------- | -------------------------------------- | | `disabled` | Nobody | — | | `authenticated` | Signed-in users | The asking user (their forwarded JWT) | | `public` | Anyone (rate-limited per IP) | **The app owner**, via `APP_OWNER_JWT` | Never silently flip an assistant to `public` — that is an owner-billed endpoint exposed to the internet. If the owner wants a public assistant, that's a config change they should see. The assistant runs on the shared DeepSpace agent runner and model catalog. **Never copy that runner, its model list, or its provider policy into app code** — a private copy goes stale and can't be fixed by an SDK upgrade. ## Customize the reader Keep the documentation feature's files together as one app-owned unit: `documentation.json`, `documentation/**/*.md(x)`, and — only if you need it — a root `documentation.tsx`. * **Omit `documentation.tsx` for the default reader.** Add it only to wrap or replace the documented React surface; it is the single shell customization point, and it's ordinary app code that can import anything in the repo. * **Never edit generated `public/_documentation*` output.** It is build output (the installer gitignores it); regenerate it by building, and keep every hand-authored asset out of it. * **Keep media referenced by pages in app-owned source** (alongside the pages or in your assets), so the build can validate and rewrite references. An image path that only exists in generated output will not survive the next build. ## Components Beyond Markdown, pages can use: | Component | For | | ------------------------------ | ----------------------------------- | | `<Note>` | An aside the reader should not skip | | `<Steps>` / `<Step title="…">` | An ordered procedure | ## Reserved paths The feature owns `/_documentation` and `/_documentation-root` (and everything under them). They are on the platform's reserved list, so an app cannot claim them and a request for one never reaches your routes. Private paths inside the corpus are refused before routing: a page under a private path answers 404 rather than being served, so drafts kept alongside published pages do not leak. ## Verify before deploying Run a local build and test pass before any deploy, and verify at minimum: Routes serve the intended release The ordinary app routes and `/docs` both serve the release you think they do. The reader works Navigation, search, Markdown/MDX rendering, and the branded not-found behavior all function. Machine surfaces point at the right host The configured machine surfaces and OpenAPI examples use the intended base URL — not localhost, not a stale domain. Assistant and MCP match config Access modes match `documentation.json`, and neither surface exposes any app-mutation tool. The basics are clean Console, keyboard navigation, mobile layout, focus, and accessibility checks pass. Do not use production as a routine documentation test target. Local dev covers the loop; rehearse risky changes on a staging environment, and let production only ever see verified releases. Source: /guides/documentation.md --- # Server actions Privileged worker-side functions that bypass user RBAC for orchestration and admin operations. Server actions are app-defined functions called from the client with the user's JWT. They run **as the app** - [RBAC](/concepts/permissions) checks are bypassed, so they can do things the user themselves can't, like updating two collections atomically or running owner-only operations. Reach for server actions when you need to: * Orchestrate writes across multiple [collections](/concepts/data-model) in one round-trip * Run admin operations (recompute analytics, send notifications, mass-update records) * Spend owner credits via an [integration](/guides/external-apis) on behalf of the user * Wrap business logic that needs server-side validation If the operation can be done with the caller's own RBAC, prefer [`useMutations`](/sdk-reference/client/records#usemutations-t-collection) on the client - keep server actions for cases that genuinely need escalation. ## Define an action ```ts // src/actions/index.ts import type { ActionHandler } from 'deepspace/worker' interface EventData { attendeeIds?: string[] } export const actions: Record<string, ActionHandler<Env>> = { inviteAttendee: async ({ params, tools }) => { const eventId = params.eventId as string const attendeeId = params.attendeeId as string const event = await tools.get('events', eventId) if (!event.success) return event const { record } = event.data as { record: { data: EventData } } const current = record.data.attendeeIds ?? [] const next = [...new Set([...current, attendeeId])] return tools.update('events', eventId, { attendeeIds: next }) }, } ``` The action is automatically exposed at `POST /api/actions/inviteAttendee`. The caller's JWT is verified before the action runs. ## Call from the client ```ts import { getAuthToken } from 'deepspace' const res = await fetch('/api/actions/inviteAttendee', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await getAuthToken()}`, }, body: JSON.stringify({ eventId, attendeeId }), }) const { success, data, error } = await res.json() ``` ## The action context Each action receives a context with the verified caller and a tools API: ```ts type ActionContext<TEnv> = { userId: string // caller (verified JWT subject) params: Record<string, unknown> // request body tools: ActionTools env: TEnv callerJwt: string // caller's raw Bearer token } ``` `callerJwt` is the verified Bearer token the action was invoked with. Forward it on outbound requests that must run as the caller (not the app owner) - see [Forwarding caller identity](#forwarding-caller-identity). ### `tools` - RBAC-bypassing operations Every method returns `ActionResult<T>` - narrow with `if (result.success)` before reading `result.data`. | Method | `data` shape on success | Notes | | ---------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `tools.create(coll, data, recordId?)` | `{ recordId }` | Create a record. Pass `recordId` to upsert against a known key. | | `tools.update(coll, id, patch)` | `{ recordId }` | Patch an existing record. | | `tools.remove(coll, id)` | `{ recordId }` | Delete a record. | | `tools.deleteWhere(coll, where, limit?)` | `{ deleted }` | Delete every record matching `where`, one bounded page per call (`limit` defaults to 100, caps at 500). Repeat until `deleted` is below the limit. `where` is required and must be non-empty - there is no "delete everything" call. | | `tools.get(coll, id)` | `{ record }` | Fetch one record (envelope: `{ recordId, data, createdAt, updatedAt, ... }`). | | `tools.query(coll, opts?)` | `{ records, count }` | List records. `opts` accepts `where`, `orderBy`, `orderDir`, `limit`. | | `tools.integration(endpoint, data?)` | the integration's response body directly | Call a third-party integration. Billing follows `src/integrations.ts`. | **Type tip.** `tools.create/update/remove` all resolve to `ActionResult<MutateActionData>` where `MutateActionData` is just `{ recordId: string }` - there is no `record` field on the result. To read the resulting row after a mutation, follow up with `tools.get(coll, recordId)`. ```ts const r = await tools.query('items', { where: { status: 'pending' } }) if (r.success) { for (const item of r.data.records) { await tools.update('items', item.recordId, { status: 'processed' }) } } ``` `tools.query` bypasses caller RBAC - your action sees every record in the collection, not just records the caller could read. If you want caller-scoped reads, do them client-side with `useQuery`, or pass a `where` clause that scopes by caller. `where` keys are schema column names plus two envelope fields, `recordId` and `createdBy` (the creator's user id) - not the storage-level `_created_by`. Values must be primitives (equality only). A key that names no field is **refused**, not silently ignored (an ignored key would hand back the whole collection as if filtered) - and the same guard runs on `tools.deleteWhere`, where an ignored key would truncate rather than over-return. **`deleteWhere` needs the app's own tools factory to have it.** The `ActionTools` interface declares it, but the factory that builds the tools lives in your app (`src/server/action-routes.ts`), so an app scaffolded before it existed has the type without the implementation. [`app update`](/cli-reference/commands#app-update) reports the `2026-08-action-tools-delete-where` migration and the exact method to add; after applying and validating it, record that id in `deepspace.migrations.json`. Note also that `deleteWhere` runs with RBAC off like every other `tools.*` call: it will not truncate a collection by accident, but it does not care who owns the rows it matches. Scope `where` to the caller yourself. **JSON columns arrive parsed.** Columns declared `interpretation: { kind: 'json' }` come back from `tools.get` / `tools.query` already deserialized - `record.data.tags` is the array or object itself, and calling `JSON.parse` on it throws. Symmetrically, pass structured values directly to `tools.create` / `tools.update` with no `JSON.stringify` - serialization happens at the worker boundary, the same contract as `useQuery` / `useMutations` on the [client](/concepts/data-model#column-types). ### Upsert by known id By default `tools.create` lets the DO mint the `recordId`. Pass an explicit id as the third argument to upsert against a known key - the canonical case is seeding the `users` row so its id matches the caller's auth user id, which is what makes `tools.get('users', userId)` resolve later. ```ts export const ensureUserRow: ActionHandler<Env> = async ({ userId, tools }) => { return tools.create('users', { displayName: 'New player', score: 0 }, userId) } ``` If a record with that id already exists, the incoming `data` is merged on top of it (existing fields you don't pass are preserved), so the same call works for both first-time seed and subsequent refreshes. ## Action return shape Actions must return `ActionResult<T>`: ```ts type ActionResult<TData> = | { success: true; data: TData; error?: never } | { success: false; data?: never; error: string } ``` Return a typed payload on success: ```ts return { success: true, data: { invitedCount: 3 } } ``` Or an error message on failure: ```ts return { success: false, error: 'Event not found' } ``` The HTTP response wraps the result in `{ success, data, error }` matching this shape. ## Owner-only actions When an action burns owner resources (credits, owner-billed integrations, sensitive owner-state mutations), gate it explicitly using `OWNER_USER_ID`: ```ts import type { ActionHandler } from 'deepspace/worker' interface OwnerEnv { OWNER_USER_ID?: string } export const recomputeAnalytics: ActionHandler<OwnerEnv> = async (ctx) => { if (ctx.env.OWNER_USER_ID && ctx.userId !== ctx.env.OWNER_USER_ID) { return { success: false, error: 'Forbidden: owner only' } } // ...privileged work... return { success: true, data: {} } } ``` `OWNER_USER_ID` is set on every deployed app to the user who owns it. Use it as the trust anchor for owner-only operations. ## Forwarding caller identity `tools.integration` already routes the right JWT for you (owner or caller, depending on `src/integrations.ts`). If you need to call a platform endpoint directly - for example a `platformWorkerFetch` or `apiWorkerFetch` where the upstream authorizes the JWT subject as *the user*, not the app - use `ctx.callerJwt` to forward the same Bearer token the action was invoked with. ```ts import type { ActionHandler } from 'deepspace/worker' import { platformWorkerFetch } from 'deepspace/worker' export const listMyApps: ActionHandler<Env> = async ({ callerJwt, env }) => { // The deploy worker's /api/apps endpoint scopes results by JWT subject, // so the caller - not the app owner - must be the authenticated user. const res = await platformWorkerFetch(env, '/api/apps', { headers: { Authorization: `Bearer ${callerJwt}` }, }) if (!res.ok) return { success: false, error: `Upstream ${res.status}` } return { success: true, data: await res.json() } } ``` `callerJwt` is a live credential. Never log it, never return it in a response body, and never embed it in URLs. The only safe destination is an outbound `Authorization: Bearer …` header to a trusted upstream. ## Integration calls - billing routing `tools.integration(endpoint, body)` proxies through the api-worker. Billing depends on `src/integrations.ts`: ```ts // src/integrations.ts export const integrations = { openai: { billing: 'developer' }, // owner pays google: { billing: 'user' }, // caller pays } ``` | `billing` setting | Who pays | | ----------------- | ------------------------------------------------ | | `'developer'` | The app owner. Anonymous callers allowed. | | `'user'` | The signed-in caller. Anonymous callers get 401. | The api-worker reads the JWT subject to bill - there's no client-supplied override. ## When to use actions vs other patterns | Need | Use | | ------------------------------------------ | --------------------------------------------------- | | Single-collection mutation the user can do | `useMutations` | | Multi-collection orchestration | Server action | | Owner-billed integration call | Server action with owner gate, or cron | | Admin operation (mass update, recompute) | Server action | | Streaming response | Custom Hono route (actions don't stream) | | Scheduled work | Cron (see [Scheduled jobs](/guides/scheduled-jobs)) | ## Testing server actions A server action is one POST endpoint; cover it in `api.spec.ts`: ```ts test('inviteAttendee adds attendee', async ({ request }) => { const token = await signInAndGetToken(request, 'alice@deepspace.test') const res = await request.post('/api/actions/inviteAttendee', { headers: { Authorization: `Bearer ${token}` }, data: { eventId: 'evt_1', attendeeId: 'usr_2' }, }) expect(res.status()).toBe(200) expect(await res.json()).toMatchObject({ success: true }) }) test('inviteAttendee requires auth', async ({ request }) => { const res = await request.post('/api/actions/inviteAttendee', { data: { eventId: 'evt_1', attendeeId: 'usr_2' }, }) expect(res.status()).toBe(401) }) ``` ## Tips * **Keep actions focused.** One verb per action (`inviteAttendee`, not `manageEvent`). Easier to test, easier to reason about. * **Don't put RBAC logic inside actions.** That's what the DO's collection permissions are for. Actions should be for orchestration and owner-gating. * **Prefer actions over ad-hoc `fetch` endpoints.** The `tools` API gives you type-safe RBAC bypass; rolling your own endpoint loses that. * **Use the caller's userId for audit logs.** `ctx.userId` is the verified caller; record it alongside any privileged write so you can trace who initiated it. ## Next steps * [Server actions reference](/sdk-reference/worker/server-actions) - `ActionHandler`, `ActionContext`, `ActionResult` types. * [Permissions](/concepts/permissions) - collection-level RBAC. * [External APIs](/guides/external-apis) - call third-party services from actions. Source: /guides/server-actions.md --- # Scheduled jobs Run cron tasks in a per-app Durable Object. DeepSpace apps include a per-app `CronRoom` Durable Object for scheduled work - digests, cleanups, periodic syncs. Tasks run on the DO's alarm, so there's no separate scheduler service to manage. You declare tasks in `src/cron.ts`; the SDK runs them and exposes a monitor hook. ## Define tasks ```ts // src/cron.ts import type { CronTask } from 'deepspace/worker' import { buildCronContext } from 'deepspace/worker' export const tasks: CronTask[] = [ { name: 'heartbeat', intervalMinutes: 1 }, { name: 'daily-digest', schedule: '0 9 * * *', timezone: 'America/New_York' }, ] export async function runTask(name: string, env: Env): Promise<void> { // Scope by the immutable app id — names are mutable URL leases. const ctx = buildCronContext(env, env.OWNER_USER_ID, `app:${env.DEEPSPACE_APP_ID}`) if (name === 'heartbeat') { await ctx.records.update('settings', 'global', { lastHeartbeat: new Date().toISOString() }) } if (name === 'daily-digest') { const users = await ctx.records.query('users', { where: { wantsDigest: true } }) for (const user of users) { const data = await ctx.integrations.call('resend/send-email', { to: user.data.email, subject: 'Your daily digest', text: '...', }) } } } ``` Each task declares **either** `intervalMinutes` (every N minutes) **or** `schedule` + `timezone` (a 5-field cron expression evaluated against an IANA timezone). Declaring both, or neither, throws at DO construction time. | Field | Type | Description | | ----------------- | --------- | -------------------------------------------- | | `name` | `string` | Unique task name; passed to `runTask`. | | `intervalMinutes` | `number` | Fire every N minutes. | | `schedule` | `string` | 5-field cron expression. | | `timezone` | `string` | IANA timezone (DST-aware). | | `paused` | `boolean` | Start disabled. Toggle via `useCronMonitor`. | ## The cron context `buildCronContext(env, ownerUserId, roomId?)` returns a context that runs as the app owner - RBAC is bypassed: | Property | Type | Description | | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | `records` | object | `query`, `create`, `update`, `delete` operations. | | `integrations` | object | `call(endpoint, params)` - proxies through the api-worker as the app owner (signed with `APP_OWNER_JWT`), billed to the owner. | | `ownerUserId` | `string` | The owner's user ID. | The records API differs from server actions - methods return their data directly rather than wrapping in `{ success, data }`: ```ts // query: returns Envelope[] (already unwrapped from the tools response) await ctx.records.query('users', { where: { active: true }, limit: 100 }) // create / update / delete: return the raw tool-call data await ctx.records.create('notifications', { userId, message }) await ctx.records.update('users', userId, { lastSeenAt: Date.now() }) await ctx.records.delete('notifications', notificationId) ``` `ctx.records.query` accepts `{ where?, limit? }` only - there is no `orderBy`/`orderDir` here. For richer filters, fetch from a server action instead. There is no `ctx.records.get(...)`; fetch one row via `query` with a `where` filter on `recordId` or call `tools.get` from a server action. Throws on failure; wrap in `try/catch` if you need to handle an error inline. ## Worker wiring The scaffolded `worker.ts` already wires `AppCronRoom`: ```ts export class AppCronRoom extends CronRoom { constructor(state: DurableObjectState, env: Env) { super(state, env, { tasks: cronTasks }) this.env = env } protected async onTask(taskName: string): Promise<void> { await runCronTask(taskName, this.env) } } ``` Don't edit those bindings - add tasks to `src/cron.ts` and the DO picks them up at construction. ## When a schedule starts running A Durable Object does not exist until something fetches it, and `CronRoom` arms its alarm inside that first fetch. So a deployed schedule that nothing has touched runs nothing - and nothing reports it. Two things close that gap, and both are already in a current scaffold: * The template `worker.ts` calls ``armCronRoom(c.executionCtx, c.env.CRON_ROOMS, `app:${c.env.DEEPSPACE_APP_ID}`, cronTasks)`` from its request path (a `*` middleware). **The first request the worker handles after a deploy arms the schedule** - once per isolate, under `waitUntil` so it never delays the response, and a no-op when the app declares no tasks. * `deploy` sends that first request for you: once the edge confirms the release (`serving: confirmed`) it fetches `GET /api/auth/ok` on the deployed app origin once, so the request middleware can arm the schedule before any visitor arrives. This is best effort - if the wake fails, the first real request arms it instead. The health response proves that the app answered and gives the middleware an opportunity to arm the room. It does **not** prove a task has executed. Trigger the task and inspect its receipt/history when you need execution proof. An app scaffolded before this wiring, or one whose `worker.ts` no longer calls `armCronRoom`, still arms only when a client opens the room. Add the middleware above by hand - `app update` has no migration guidance for it - and confirm with the run lines below rather than by waiting. Every run logs one line, so `deepspace logs --search cron` shows the schedule working: `[cron] <task> ok <ms>ms`, or `[cron] <task> failed <ms>ms: <message>` at level `error` with the stack as plain text. The message is in the line on purpose - the Workers runtime renders a logged Error object as its stack frames without the message. A run that throws is a **caught** failure: it is a `log` event, not an `exception` event, and no invocation reports `outcome: "exception"` for it; see [reading an event](/cli-reference/commands#reading-an-event). Cron history rows (`useCronMonitor`'s `history`) record the same success/failure and duration. ## Monitor and trigger from the UI - `useCronMonitor` ```tsx import { useState } from 'react' import { useCronMonitor, useUser, type CronMutationResult } from 'deepspace' import { SCOPE_ID } from '../constants' function CronAdmin() { const { tasks, history, connected, canWrite, lastError, trigger, pause, resume } = useCronMonitor(SCOPE_ID) const { user } = useUser() const isAdmin = user?.role === 'admin' const [mutationResult, setMutationResult] = useState<string | null>(null) const runMutation = async (mutation: Promise<CronMutationResult>) => { setMutationResult('pending') const receipt = await mutation setMutationResult(receipt.ok ? 'ok' : receipt.error ?? receipt.reason) } if (!connected) return <p>Connecting…</p> return ( <div> <h2>Tasks</h2> <ul> {tasks.map((t) => ( <li key={t.name}> <strong>{t.name}</strong> - next: {t.nextRunAt} {isAdmin && ( <> <button aria-label={`Run now: ${t.name}`} disabled={!canWrite} onClick={() => void runMutation(trigger(t.name))} > Run now </button> <button disabled={!canWrite} onClick={() => void runMutation(t.paused ? resume(t.name) : pause(t.name))} > {t.paused ? 'Resume' : 'Pause'} </button> </> )} </li> ))} </ul> {lastError && <p role="alert">{lastError}</p>} {mutationResult && ( <p data-testid="cron-trigger-result" aria-live="polite">{mutationResult}</p> )} <h2>History</h2> <ul> {history.map((h, i) => ( <li key={i} data-testid="cron-history-row"> {h.taskName} - {h.success ? 'ok' : 'failed'} - {h.durationMs}ms </li> ))} </ul> </div> ) } ``` | Return | Type | Description | | --------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `tasks` | `CronTaskState[]` | Live state of each task. | | `history` | `CronHistoryEntry[]` | Recent runs (with success, duration, error). | | `connected` | `boolean` | WebSocket connection status. | | `canWrite` | `boolean` | RBAC gate from the server. False until the AUTH frame lands; stays false for read-only viewers. | | `lastError` | `string \| null` | Most recent room error frame. | | `trigger(name)` | `(name: string) => Promise<CronMutationResult>` | Run `onTask` immediately; resolves after the run completes. | | `pause(name)` | `(name: string) => Promise<CronMutationResult>` | Disable a task and return the server receipt. | | `resume(name)` | `(name: string) => Promise<CronMutationResult>` | Re-enable a task and return the server receipt. | `CronMutationResult` is `{ ok: true, taskName, requestId }` or `{ ok: false, reason, error? }`, where `reason` is `'read_only'`, `'not_connected'`, `'unknown_task'`, or `'failed'`. Always check the receipt when the next UI step depends on success. **Members and admins can fire owner-billed tasks by default.** The Cron DO authorizes `trigger` / `pause` / `resume` off the role the WebSocket route resolves, and the scaffolded `/ws/cron/:roomId` resolves each authenticated caller's **current app role** via `resolveAppRole` (owner → `admin`; otherwise the role stored on their `users` row). Anonymous connections carry no role, and `CronRoom` enforces viewers and anonymous connections as read-only - their write calls are denied server-side. For admin-only writes, customize the route's role resolver to return a writer role only for admins. Client-side gating by `canWrite` (or `user?.role === 'admin'`) keeps the buttons honest, but the role check at the DO is the security boundary. ## Outbound calls in handlers `runTask` runs as the app owner. Use `ctx.integrations.call(...)` for third-party APIs (billed to `APP_OWNER_JWT`): ```ts const data = await ctx.integrations.call('openai/chat-completion', { model: 'gpt-5.6-terra', messages: [{ role: 'user', content: 'Summarize today\'s activity' }], }) ``` For autonomous LLM calls via the AI SDK: ```ts import { createDeepSpaceAI } from 'deepspace/worker' import { generateText } from 'ai' const ai = createDeepSpaceAI(env, 'anthropic') // no authToken → owner pays const { text } = await generateText({ model: ai('claude-haiku-4-5'), prompt: '…', }) ``` ## Testing without waiting for the schedule `trigger(taskName)` runs `onTask` immediately via the same task-execution path as the alarm. In tests, assert the receipt before checking history: ```ts test('daily-digest fires when triggered', async ({ page }) => { await page.goto('/cron-admin') await page.getByRole('button', { name: /run now: daily-digest/i }).click() await expect(page.getByTestId('cron-trigger-result')).toHaveText('ok') await expect(page.locator('[data-testid="cron-history-row"]')).toBeVisible() }) ``` Don't wait for `intervalMinutes: 1` to tick in tests - it's slow and flaky. Trigger explicitly. `trigger` runs `onTask` directly and **bypasses alarm wake-up and scheduling**. If you need to verify the alarm path itself, use a task with `intervalMinutes: 1` and budget about 130 seconds for the tick and history row. Reserve that for cron infrastructure; app-level task logic should test through `trigger`. ## A ready-made monitor page ```bash npx deepspace add cron ``` This inserts a 1-minute `heartbeat` task at the generic insertion point in `src/cron.ts`, and installs a read-only page at `src/pages/(app)/cron-log.tsx` that subscribes via `useCronMonitor(SCOPE_ID)` and renders tasks + history. The page lands under `(app)/` deliberately - that's the route group where the providers mount, which the hook needs. It does **not** expose `trigger` / `pause` / `resume` - add those yourself with the admin gating above. If you've customized `src/cron.ts` and the insertion marker is gone, the installer refuses with exact manual-integration steps instead of overwriting your file. Apply the listed insertion by hand. ## Migrating a pre-CronRoom setup If an existing app has a `cron.json`, a `handleCron` function, or an `/internal/cron` route, that is the retired pattern from before the per-app `CronRoom`. There is no cron JSON manifest, no cron HTTP endpoint, and no centralized dispatcher - every app schedules and runs its own work in its own DO via Cloudflare alarms. Delete those artifacts and rewrite to the shape on this page: 1. Move task declarations into `src/cron.ts` as `CronTask[]` entries. 2. Move handler logic into `runTask(name, env)`, using `buildCronContext` scoped to `app:` + `env.DEEPSPACE_APP_ID` for all record mutations and integration calls - it signs `ctx.integrations.call(...)` with `APP_OWNER_JWT` so you don't hand-roll request auth. 3. Delete the old route and manifest. The scaffolded `worker.ts` wiring (`AppCronRoom` + `/ws/cron/:roomId`) replaces them. ## Next steps * [Worker cron reference](/sdk-reference/worker/cron) - `CronRoom`, `CronTask`, `buildCronContext`. * [Server actions](/guides/server-actions) - privileged on-demand operations. * [External APIs](/guides/external-apis) - calling third-party APIs from your worker. Source: /guides/scheduled-jobs.md --- # Background jobs Durable, observable background work that outlives the HTTP response. DeepSpace apps include a per-app `AppJobRoom` Durable Object for background work that can't or shouldn't run inside an HTTP handler - long AI generations, CSV exports, image renders, bulk imports, fan-out side effects. Jobs are persisted in SQLite, survive isolate restarts, and broadcast every state change over WebSocket so clients see live progress and can cancel or retry without polling. Use this **instead** of `ctx.waitUntil(...)`. `waitUntil` is killed 30 seconds after the response goes out - the JobRoom replaces that pattern. For work that runs on a schedule (daily digest, hourly sync), use [scheduled tasks](/guides/scheduled-jobs); for privileged work that finishes inside the HTTP response, use [server actions](/guides/server-actions). **`enqueue` / `cancel` / `retry` require a verified member or admin write role by default.** The scaffolded `AppJobRoom` passes an `authorizeWrite` callback that rejects anonymous connections and resolves each caller's current app role; the SDK re-checks it on every mutation, not just at connect, so a revoked role takes effect immediately. A denied `enqueue` rejects with a write-access error. Client-side gating (hiding buttons behind a role check) is UX - the `authorizeWrite` check at the DO is the security boundary. Tighten it to admin-only for jobs that spend owner credits. ## When to use jobs vs. cron vs. server actions | If the work… | Use | | ------------------------------------------------------------ | --------------------------------------------------------------- | | Finishes inside the HTTP response | A regular Hono route or [server action](/guides/server-actions) | | Runs on a schedule (daily digest, hourly sync) | [Scheduled tasks](/guides/scheduled-jobs) | | Is triggered by a user click and may take seconds to minutes | **Background jobs** (this guide) | | Is triggered by the worker and needs to outlive the response | **Background jobs** (this guide) | ## Define handlers in `src/jobs.ts` A single `runJob` function dispatches every job type. It receives the [`Job`](/sdk-reference/worker/rooms#jobroom-e) row and a [`JobContext`](/sdk-reference/worker/rooms#jobroom-e), returns the result on success, and throws to fail. ```ts import type { Job, JobContext } from 'deepspace/worker' export async function runJob( job: Job, ctx: JobContext, env: Env, ): Promise<unknown | void> { if (job.type === 'ai-summarize') { const { text } = job.payload as { text: string } ctx.progress(0.1, 'starting') // Pass ctx.signal so Cancel actually aborts the upstream fetch. const summary = await callModel(text, { signal: ctx.signal }) return { summary, words: summary.split(/\s+/).length } } if (job.type === 'export-csv') { // ... long export, call ctx.progress(p, msg) periodically } // Unknown type → fail loudly so it shows up in the failed list. throw new Error(`Unknown job type: ${job.type}`) } ``` The return value becomes `job.result` and **must be JSON-serializable**. Throwing fails the job; if `attempts < maxAttempts`, the job retries on the next alarm tick. There is no `ctx.complete()` or `ctx.fail()` - return or throw to control the outcome. ### The `JobContext` API | Member | Purpose | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ctx.progress(value, message?)` | Broadcast progress (0..1). Re-renders any `useJobs` subscriber. | | `ctx.signal` | `AbortSignal` that fires when the client calls `cancel(id)` (same isolate). Pass to `fetch(url, { signal: ctx.signal })` to abort upstream requests cleanly. | | `ctx.continue(state, { afterMs? })` | Checkpoint state and yield to the next alarm tick. Use for jobs that exceed the \~15-minute wall budget. Call `ctx.continue(...)` **then `return`** - the next tick invokes `onJob` again with `job.resumeFrom = state`. | ## Worker wiring The scaffolded `worker.ts` already wires `AppJobRoom`: ```ts export class AppJobRoom extends JobRoom<Env> { constructor(state: DurableObjectState, env: Env) { super(state, env) } protected async onJob(job: Job, ctx: JobContext): Promise<unknown> { return await runJob(job, ctx, this.env) } } ``` Don't edit the binding or route - add job types in `src/jobs.ts` and the DO picks them up. ## Enqueueing - two entry points, one queue Two enqueue paths write to the same DO row. Pick by **where the caller lives**. ### From the client - `useJobs` The [`useJobs`](/sdk-reference/client/realtime#usejobs-roomid) hook returns a live `jobs` list and an `enqueue` function. Every connected subscriber sees the same state transitions in real time. ```tsx import { useJobs } from 'deepspace' import { SCOPE_ID } from '../constants' function ExportButton() { const { enqueue, jobs, cancel, retry } = useJobs(SCOPE_ID) return ( <> <button onClick={() => enqueue('export-csv', { filterId: 'q1' }, { maxAttempts: 2 })}> Run export </button> <ul> {jobs.map((j) => ( <li key={j.id}> {j.type} - {j.status} {j.progress != null && <> ({Math.round(j.progress * 100)}%)</>} {j.status === 'running' && <button onClick={() => cancel(j.id)}>Cancel</button>} {j.status === 'failed' && <button onClick={() => retry(j.id)}>Retry</button>} </li> ))} </ul> </> ) } ``` `enqueue` resolves with the `jobId` once the server acks. `jobs` is sorted with live/recent first and re-renders on every state change. ### From the worker - `enqueueJob` Use this from HTTP routes, server actions, cron handlers, AI routes - anywhere the JobRoom DO isn't the current isolate. ```ts import { enqueueJob } from 'deepspace/worker' app.post('/api/start-export', async (c) => { const auth = await resolveAuth(c.req.raw, c.env) if (!auth) return c.json({ error: 'unauthorized' }, 401) const jobId = await enqueueJob( c.env.JOB_ROOMS, `app:${c.env.DEEPSPACE_APP_ID}`, // immutable app id — names are mutable URL leases 'export-csv', { filterId: '...' }, { maxAttempts: 2, enqueuedBy: auth.userId }, ) return c.json({ jobId }) }) ``` Inside `AppJobRoom.onJob(...)` itself, call `this.enqueue('next-step', payload)` to chain follow-up work - the in-isolate call skips the HTTP hop that `enqueueJob` makes from outside: ```ts export class AppJobRoom extends JobRoom<Env> { protected async onJob(job: Job, ctx: JobContext): Promise<unknown> { const result = await runJob(job, ctx, this.env) if (job.type === 'export-csv') this.enqueue('email-export', { jobId: job.id }) return result } } ``` ## Lifecycle and limits Every default below is fixed when the DO is constructed. Override them by passing a config object to `super(state, env, { ... })` inside `AppJobRoom` - see the [`JobRoomConfig` reference](/sdk-reference/worker/rooms#jobroom-e) for every knob. | Concern | Default | How to change | | ---------------------- | ----------------------- | --------------------------------------------------------------------- | | Retry on throw | None (`maxAttempts: 1`) | Pass `{ maxAttempts: N }` to `enqueue` | | Retry backoff | 1 s | Override `retryBackoffMs` on `AppJobRoom` config | | Terminal-row retention | 24 h | Override `retentionMs` on `AppJobRoom` config | | Per-tick wall budget | \~15 min | Chain with `ctx.continue(state)` | | Crash recovery | Auto | Rows stuck `running` past \~16 min are rescued on the next DO wake-up | State machine: `queued → running → succeeded | failed | canceled`. Crash-recovery outcomes are deterministic: * A rescued `running` row is **retried if attempts remain**, otherwise marked `failed`. * A cancel that lands while the handler is running flips the row to `canceled`; a return value that arrives afterward (including from another isolate) is **discarded** rather than overwriting the canceled state. * `useJobs` auto-reconnects on WebSocket drop, so subscribers converge on the recovered state without a refresh. ## Outbound calls in handlers Handlers run as the app owner, just like [scheduled tasks](/guides/scheduled-jobs). Use [`createDeepSpaceAI(env, 'anthropic')`](/sdk-reference/worker/ai) for [AI calls](/guides/ai-chat) - it falls back to `APP_OWNER_JWT` and bills the developer. Pass `ctx.signal` to every `fetch(...)` so client cancel aborts cleanly upstream: ```ts const res = await fetch('https://api.example.com/render', { method: 'POST', body: JSON.stringify({ ... }), signal: ctx.signal, }) ``` ## Who can enqueue The scaffolded `AppJobRoom` authorizes writes server-side: ```ts export class AppJobRoom extends JobRoom<Env> { constructor(state: DurableObjectState, env: Env) { super(state, env, { authorizeWrite: async (user) => { if (user.userId.startsWith('anon-')) return false const role = await resolveAppRole(env, user.userId) return role === 'member' || role === 'admin' }, }) } } ``` `enqueue`, `cancel`, and `retry` therefore require a verified user whose current app role is member or admin, and the check runs again before every mutation. Hiding the button behind `useUser().user?.role === 'admin'` or a `(protected)/` route is good UX, but it is not what stops an unauthorized enqueue - `authorizeWrite` is. Two patterns on top of the default: * **Paid jobs stay owner-only.** If a handler spends owner credits (integrations, AI proxies), tighten `authorizeWrite` to `role === 'admin'` - an ordinary member shouldn't be able to spend the owner's credits from the console. * **A deliberately public producer goes through HTTP, not the socket.** Don't loosen `authorizeWrite` to let anonymous connections write. Instead, expose one app-owned HTTP action that validates a **named job type** and a **bounded payload**, rate-limits the caller, and then calls `enqueueJob` server-side: ```ts app.post('/api/request-summary', async (c) => { const { text } = await c.req.json<{ text?: string }>() if (typeof text !== 'string' || text.length > 10_000) { return c.json({ error: 'invalid_payload' }, 400) } // Rate-limit here (per IP or per user) before spending anything. const jobId = await enqueueJob( c.env.JOB_ROOMS, `app:${c.env.DEEPSPACE_APP_ID}`, 'ai-summarize', // one named type — never a caller-chosen type { text }, ) return c.json({ jobId }) }) ``` The route owns validation, rate limits, and which job types the public may create; the DO's write role keeps every other path closed. ## Testing without waiting for a real upstream Two approaches work well: 1. **Use a fast handler in tests.** A job type like `'echo'` that returns its payload with no I/O lets you assert the full enqueue → run → succeed pipeline in under a second without mocking upstreams. 2. **Hit the enqueue route from a Playwright spec.** Render a page that uses `useJobs`, click the enqueue button, then assert against the rendered status: ```ts test('export job succeeds end-to-end', async ({ page }) => { await page.goto('/jobs') await page.getByRole('button', { name: /run export/i }).click() await expect( page.locator('[data-testid="job-row"][data-status="succeeded"]'), ).toBeVisible({ timeout: 30_000 }) }) ``` Don't write tests that wait for the 16-minute crash-recovery sweep, and don't manually flip DB rows - use the public `enqueue` / `cancel` / `retry` surface. ## Next steps * [Worker rooms reference](/sdk-reference/worker/rooms#jobroom-e) - `JobRoom`, `Job`, `JobContext`, `enqueueJob`. * [Real-time reference](/sdk-reference/client/realtime#usejobs-roomid) - `useJobs` return shape. * [Scheduled tasks](/guides/scheduled-jobs) - if the work needs to run on a schedule instead of on demand. * [Server actions](/guides/server-actions) - for privileged work that finishes inside the HTTP response. Source: /guides/background-jobs.md --- # Managed knowledge Upload and search an app-owned AI Search knowledge base with the DeepSpace worker helper. DeepSpace can provision one managed Cloudflare AI Search instance per app. Declare it in `wrangler.toml`; the app calls it through the typed `knowledge(env)` worker helper, and usage is charged to the app owner. ## Configure the binding ```toml [[ai_search]] binding = "KNOWLEDGE" instance_name = "auto" ``` `instance_name` must be `"auto"`, and an app may declare only one `ai_search` binding. DeepSpace owns provisioning and app isolation. The `KNOWLEDGE` name is manifest metadata rather than an AI Search object you call directly from application code. ## Use it from the worker ```ts import { knowledge } from 'deepspace/worker' const kb = knowledge(env) const added = await kb.add( new File( ['DeepSpace keeps records in app-owned Durable Objects.'], 'architecture.md', { type: 'text/markdown', }, ), { folder: 'docs' }, ) const results = await kb.search('Where are records stored?', { folder: 'docs', mode: 'hybrid', limit: 5, }) ``` The helper uses the app's signed platform transport. It does not read `env.KNOWLEDGE` as a provider binding. ## API ```ts const kb = knowledge(env) await kb.add(file, { folder: 'docs' }) await kb.list({ folder: 'docs', page: 1, perPage: 20, status: 'completed' }) await kb.remove(itemId) await kb.search(query, { folder: 'docs', mode: 'hybrid', limit: 5 }) const docs = kb.scoped({ folder: 'docs' }) await docs.add(file) await docs.list({ page: 1, perPage: 20 }) await docs.search(query, { mode: 'semantic', limit: 5 }) ``` `scoped` fixes the folder for add, list, and search. Remove remains on the root client because it addresses a provider item ID directly. Items report one of these statuses: `queued`, `running`, `completed`, `error`, `skipped`, or `outdated`. Upload completion means the provider accepted the item; use `list` to observe indexing status before assuming it is searchable. ## Limits and validation * A provider upload part may be at most 4 MiB. The SDK safely splits oversized text files on UTF-8 boundaries; oversized non-text files are rejected so application code can use a format-aware splitter. * A query may contain at most 4,096 characters. * `limit` and `perPage` must be integers from 1 through 50. * Folders are relative forward-slash paths. Empty segments, `.` / `..`, backslashes, control characters, and leading slashes are rejected. Folder plus filename must fit the provider's 128-character item-key limit. * `mode` is `hybrid`, `semantic`, or `fulltext`. ## Pricing The amounts below include the 10% managed-binding markup: | Operation | Price | | ------------------------- | --------------------------------------------------------------: | | Text ingestion | $0.825 per 1 million provider-reported tokens | | Image ingestion | $0.55 per 1 million image tokens, in addition to text ingestion | | Storage | $2.20 per GB-month | | Hybrid or semantic search | $0.825 per 1,000 queries | | Full-text search | $0.11 per 1,000 queries | Charges settle against actual provider usage when it is available. Uploads reserve a conservative amount first, so a rejected or smaller operation does not become an unbounded charge. DeepSpace credits use 100 credits per US dollar, and managed-knowledge usage appears in the account-wide usage surfaces. ## Errors Failures throw `KnowledgeError`, which includes `status` and a machine-readable `code`. A split text upload can partially succeed; in that case `uploadedItems` names the accepted parts so you can avoid uploading them twice. ```ts import { KnowledgeError, knowledge } from 'deepspace/worker' try { await knowledge(env).add(file) } catch (error) { if (error instanceof KnowledgeError) { console.error(error.code, error.uploadedItems) } } ``` ## See also * [Bindings reference](/sdk-reference/worker/bindings) * [Custom bindings](/guides/custom-bindings) * [App usage](/cli-reference/commands#app-usage) Source: /bindings/knowledge.md --- # External APIs Call third-party APIs through the platform's integration proxy. DeepSpace fronts 215+ third-party API endpoints - LLMs (Claude, GPT, Cerebras), search (Exa, Tavily), media (LiveKit, Resend), finance (Finnhub, Alpha Vantage), social (Discord, Slack), Google Workspace, weather, and more - through a single signed proxy. You don't store API keys, configure webhooks, or build per-vendor SDKs. You call `integration.post(...)`, the platform handles billing, rate-limiting, and provider routing. ## Calling an integration ```ts import { integration } from 'deepspace' const result = await integration.post('openweathermap/geocoding', { q: 'Brooklyn' }) if (result.success) { console.log(result.data) } else { console.error(result.error) } ``` Endpoint names are always two segments: `<integration>/<endpoint>`. The response is a discriminated envelope: ```ts type IntegrationResponse<T> = | { success: true; data: T } | { success: false error: string // human-readable, safe to render directly code?: string // machine slug (e.g. 'insufficient_credits') — branch on this, never render it status?: number // HTTP status of the failed response; 0 on transport failure (timeout/abort/network) details?: Record<string, unknown> // structured fields the server sent (e.g. availableCredits) issues?: ValidationIssue[] } ``` On failure, **render `error` and branch on `code`** - the client normalizes every error so `error` is always the human sentence and `code` is the stable slug. When the api-worker's Zod validator rejects a body, `issues` carries field-level errors: ```ts if (!result.success) { showToast(result.error) // human text, safe to render if (result.code === 'insufficient_credits') openBilling() for (const issue of result.issues ?? []) { console.log(issue.path, issue.message) // field-level, on a validation failure } } ``` ## Discover endpoints - `deepspace integrations` The CLI exposes the full catalog and lets you invoke endpoints interactively. **Discovery is free; calls are billed** - `list` and `info` work without authentication, so you can scope integration work before deciding whether to log in. List catalog No login required. ```bash # Human-readable npx deepspace integrations list # Machine-readable (JSON) npx deepspace integrations list --json ``` Prints every endpoint key grouped by integration, each with a one-line description, its billing model and base cost, and an `[oauth]` tag where the platform manages the OAuth connection. Run `info` on anything you want the full schema for. Inspect one endpoint No login required. ```bash npx deepspace integrations info openai/chat-completion npx deepspace integrations info openai/chat-completion --json ``` Prints the endpoint key, its description, its billing (cost and unit), a Requires-OAuth explanation where the endpoint needs a connection, the **input** schema as JSON Schema, and - for endpoints whose success shape the platform curates - the **output** schema too. Use this before guessing field names: the input schema is what the api-worker validates against, and the output schema is the response shape to expect. The printed example body is the catalog's own example when it has one; otherwise it is synthesized from the input schema's **required** keys, with placeholders taken from the schema (`example`, `default`, first `enum`) or the type (`"<string>"`, `0`, `false`), so it is never `{}` for an endpoint that rejects `{}`. It is a starting point, not a valid request - replace the placeholders. Not every endpoint carries an output schema; where none is shown, make one `invoke` test call and read the real envelope rather than guessing response nesting. Invoke (test call) Login required, billed to the logged-in user. ```bash # Body inline npx deepspace integrations invoke openai/chat-completion --body '{ "model": "claude-sonnet-5", "messages": [{"role": "user", "content": "Hello"}] }' # Body from file or stdin npx deepspace integrations invoke openai/chat-completion --body-file request.json cat request.json | npx deepspace integrations invoke openai/chat-completion --body-file - ``` Useful for verifying a body shape end-to-end before wiring the call into your app. A paid endpoint asks first. At an interactive terminal the prompt names the price and defaults to **No**; declining is a clean success with `cancelled: true`. Under `--json`, with a piped stdin (`--body-file -`), or in CI there is no prompt: the call refuses `cost_confirmation_required` before anything is billed, and `--yes` is how you consent. Free endpoints never prompt. See the [CLI reference](/cli-reference/commands#integrations) for the full flag list. ## Billing - developer vs user Every integration has a billing setting in `src/integrations.ts`: ```ts // src/integrations.ts export const integrations: Record<string, { billing: 'developer' | 'user' }> = { google: { billing: 'user' }, // already in scaffold (OAuth requires user-pays) openai: { billing: 'developer' }, // owner pays exa: { billing: 'developer' }, } ``` | Setting | Who pays | Anonymous callers | | ----------------------- | --------------------------------- | ----------------- | | `'developer'` (default) | The app owner via `APP_OWNER_JWT` | Allowed | | `'user'` | The signed-in caller | Blocked with 401 | Billing routing is decided entirely by `src/integrations.ts` and the JWT the proxy forwards - **the caller cannot redirect billing with a header**. The api-worker always charges the JWT subject and ignores billing-override headers on integration calls, so no client-supplied header can switch who pays. **Auth-gate and app-rate-limit every UI that can trigger a paid call.** The api-worker accepts anonymous callers for `'developer'`-billed integrations, so a public endpoint silently bills the owner for every visitor (or bot) hit - wrap calling components in `useAuth().isSignedIn`. But auth-gating alone is not spend control: any signed-in user can still click a paid button in a loop, and the platform rate-limits only restricted integrations (`google/*`) - everything else passes through. Add your own limit at the app layer - debounce the trigger, disable the button while a call is in flight, and cap calls per user per session for anything expensive. ## Response shapes `data` shape varies by endpoint. Common patterns: ```ts // List endpoint const r = await integration.post('exa/search', { query: 'climate change papers' }) if (r.success) { for (const result of r.data.results) { console.log(result.title, result.url) } } // Detail endpoint const r = await integration.post('finnhub/stock-quote', { symbol: 'AAPL' }) if (r.success) { console.log(r.data.c, r.data.h, r.data.l) // current, high, low } ``` **Empty results are not errors.** Some endpoints return `success: true` with empty data when the upstream has no matches. Check for empty state explicitly. For example, `finnhub/stock-price` returns an all-zero quote for an invalid symbol - the call "succeeds" but the data is meaningless. ## Calling from your worker Inside server actions and cron tasks, use `tools.integration` or `ctx.integrations.call`: ```ts // Server action export const summarizeDay: ActionHandler<Env> = async ({ tools }) => { const r = await tools.integration('openai/chat-completion', { model: 'gpt-5.6-terra', messages: [{ role: 'user', content: 'Summarize today\'s activity' }], }) // ... } // Cron task export async function runTask(name: string, env: Env) { const ctx = buildCronContext(env, env.OWNER_USER_ID, `app:${env.DEEPSPACE_APP_ID}`) const r = await ctx.integrations.call('resend/send-email', { to, subject, text }) } ``` Both routes go through the api-worker proxy. Billing follows `src/integrations.ts` - when called from a server action, `'user'` integrations bill the caller and `'developer'` ones bill the owner. ## Request options The `post`, `get`, `put`, and `delete` methods accept an options object: ```ts const r = await integration.post('exa/search', body, { timeoutMs: 30_000, // default 120s headers: { 'X-Custom': 'value' }, }) ``` ## OAuth integrations Google Workspace endpoints (`google/*` - Gmail, Calendar, Drive, Contacts) require per-user OAuth and are always billed as `'user'` - the scaffold ships Google that way, and it must stay that way. See [Google OAuth](/guides/google-oauth) for the full contract; the essentials: * **The `requiresOAuth` response is success-shaped.** When the user has no tokens, lacks a scope, or their token was revoked, the endpoint returns HTTP 200 `{ success: true, data: { requiresOAuth: true, provider, scopes, authUrl } }`. Check `result.data?.requiresOAuth` - never `success === false` - and send the user to `authUrl`. * **There is no separate auth-url endpoint.** POST the real endpoint whose scope you need; the `requiresOAuth` payload carries an `authUrl` built for exactly those scopes, so one code path serves the connect button and the data load. * **Gate features per scope**, never on a composite "is connected" boolean. ## UI states for integration data Every integration-backed view needs four states: **loading**, **error with a local retry**, **empty**, and **success**. Empty is not error (see the note above), and error must recover in place - **a failed resource must never reload the whole page**. Full-page reloads throw away app state, re-fire every other request on the page, and turn one flaky upstream into a broken app. The SDK ships two hooks for this - use them instead of hand-rolling `useEffect` fetch state: * [`useAsyncResource`](/sdk-reference/client/integrations#useasyncresource) for one-shot calls - a lookup, a single completion, a status check. * [`usePagedResource`](/sdk-reference/client/integrations#usepagedresource) for feeds - it pages on demand and clamps oversized pages, so a feed stays bounded instead of fetching an entire upstream dataset. ```tsx import { integration, useAsyncResource } from 'deepspace' function Quote({ symbol }: { symbol: string }) { const quote = useAsyncResource( async (signal) => { const r = await integration.post('finnhub/stock-quote', { symbol }, { signal }) if (!r.success) throw new Error(r.error) // throw so the hook captures the error return r.data as { c: number } }, [symbol], ) if (quote.status === 'loading') return <Spinner /> if (quote.status === 'error') return <ErrorNote message={quote.error} onRetry={quote.reload} /> if (!quote.data) return <EmptyState label="No quote" /> return <Price value={quote.data.c} /> } ``` The retry button calls `reload()` (or `retry()` on the paged hook) - the request re-fires in place and the rest of the page stays untouched. Full signatures live in the [integrations reference](/sdk-reference/client/integrations#async-resource-hooks). ## Testing integrations Integration calls hit real third-party services and **cost real money**. Keep integration assertions minimal: * One `integration.post(...)` per endpoint per test run, not a matrix * Never put integration calls inside `for` loops or retry-until-success polls * Skip `'user'`-billed endpoint calls in `api.spec.ts` - test accounts have no credits and will 402 * **Never flip billing modes to make a test pass.** Switching an integration from `'user'` to `'developer'` so a test account can call it changes the app's production security and spend model to satisfy a test - fix the test instead (skip the call, or mock the boundary) * **Mock only the external integration boundary.** When a real call would charge money, mutate provider state, or require unavailable credits, `page.route(...)` the integration response - and keep app-internal hooks, routes, and components real, so the test still exercises your actual code For the integration call itself, assert the envelope shape - not the upstream provider's exact response: ```ts test('weather lookup returns coords', async ({ request }) => { const token = await signInAndGetToken(request) const r = await request.post('/api/integrations/openweathermap/geocoding', { headers: { Authorization: `Bearer ${token}` }, data: { q: 'Brooklyn' }, }) const body = await r.json() expect(body.success).toBe(true) expect(body.data[0]).toMatchObject({ lat: expect.any(Number), lon: expect.any(Number) }) }) ``` ## Tips * **Run `info` before guessing a body shape.** The Zod schemas the api-worker validates against are the source of truth - `npx deepspace integrations info <endpoint>` prints them with an example body. * **Use `tools.integration` from server actions for owner-pays endpoints.** Keeps the JWT scope correct and centralizes routing. * **For LLM streaming**, use the AI chat pipeline (see [AI chat](/guides/ai-chat)) rather than `integration.post`. The proxy returns the full response; the AI helpers stream it. ## Next steps * [Google OAuth](/guides/google-oauth) - the per-user consent contract for `google/*`. * [LiveKit rooms](/guides/livekit) - audio/video room lifecycle and reserve-then-settle billing. * [AI chat](/guides/ai-chat) - streamed LLM responses with tool use. * [Server actions](/guides/server-actions) - call integrations from worker code. * [Integrations reference](/sdk-reference/client/integrations) - full `integration` API. Source: /guides/external-apis.md --- # Google OAuth Per-user Google consent - the requiresOAuth contract, scope gating, connection status, and test mocks. The `google/*` endpoints (Gmail, Calendar, Drive, Contacts) are the platform's per-user OAuth surface: each signed-in user connects their own Google account, and the platform stores and auto-refreshes their tokens. Consent is incremental - users grant scopes one feature at a time, and the platform unions newly granted scopes with previously granted ones, so per-feature acquisition compounds instead of resetting. This page is the contract for wiring `google/*` calls: building a "Connect Google" button, handling the `requiresOAuth` response, gating UI per scope, and mocking the OAuth surface in tests. For the general integration client, see [external APIs](/guides/external-apis) and the [integrations reference](/sdk-reference/client/integrations). ## Billing must be `'user'` The scaffold ships Google with `billing: 'user'`. That setting is non-negotiable for `google/*`: ```ts // src/integrations.ts export const integrations: Record<string, { billing: 'developer' | 'user' }> = { google: { billing: 'user' }, // already in scaffold - never change to 'developer' } ``` OAuth tokens are stored per user, keyed by the JWT subject. With `'developer'` billing the proxy forwards the **owner's** JWT for every call - so every visitor's clicks would read and write the *owner's* connected Gmail, Drive, and Calendar, regardless of who is signed in client-side. `'user'` billing is what makes each call operate on the caller's own account. ## There is no separate auth-url endpoint To get an `authUrl` for a "Connect Google" button, POST the real endpoint whose scope you need. When the user isn't authorized, the response carries an `authUrl` built for exactly that endpoint's scopes; when they are, it carries the data. One code path serves both the connect button and the data load: | Feature intent | Endpoint to POST | | ---------------- | ----------------------------- | | Calendar feature | `google/calendar-list-events` | | Gmail read | `google/gmail-list` | | Gmail compose | `google/gmail-send` | | Drive feature | `google/drive-list` | | Contacts feature | `google/contacts-list` | Each `google/*` endpoint requests the **scope set its feature needs** - and every one of those sets is a single scope targeting exactly one Google API surface. Posting to `google/calendar-list-events` requests calendar events access only - it does not also request Gmail access, even if your app needs both. The set is per *feature*, not per verb: `google/gmail-send` requests `gmail.modify`, the platform's one Gmail write scope, so consenting from the Send button lights `gmailModify` too. On top of the feature scope, every auth URL always appends the `openid` / `userinfo.email` / `userinfo.profile` identity scopes. Scopes still accumulate one feature at a time through incremental consent; do not try to collect them all up front. ## The `requiresOAuth` response is success-shaped When a `google/*` endpoint is called without stored tokens, without a required scope, or with a stored token Google rejects **at call time**, the api-worker returns the OAuth-required payload as a **normal handler result** - HTTP 200, wrapped in the standard success envelope: ```ts { success: true, // yes, true - this is not an error envelope data: { requiresOAuth: true, provider: 'google', scopes: string[], // the scopes this call needs authUrl: string // send the user here to grant consent } } ``` One shape covers all three of those classes - no stored tokens, insufficient scope (Google 403), and a token Google rejects on use (401) - so a single check handles them. On that hard 401 the platform also clears the stale token row, so the next status poll honestly reports "not connected". One revocation path does **not** take this shape. When the hour-scale access token has expired and the platform's **refresh** of it fails - the classic case: the user revoked the app in their Google account settings while idle - the refresh error surfaces as a plain integration failure: HTTP 502, `success: false`, `error: 'upstream_provider_error'`, with a `message` beginning `Token refresh failed`. The token row is **not** cleared on this path, so connection status keeps reporting `connected: true` until the user re-consents. Treat that signature as an OAuth recovery case - surface the reconnect flow - not as a retryable provider error. **Check `result.data?.requiresOAuth`, and treat a failed token refresh as reconnect too.** The SDK forwards the api-worker's `data` field as-is, so the OAuth fields sit one level down. A `{ success: false, error: 'requiresOAuth', connectUrl }` shape does not exist - the field is `authUrl`, and it rides inside `data` on a successful response. On the `success === false` branch, the one OAuth case is the refresh-failure signature above; the rest of that branch is network, validation, and proxy errors. Client pattern - unwrap with `data ?? result` so the same code handles the nested envelope and any flattened variant: ```ts const result = await integration.post('google/gmail-send', { to, subject, content }) if (!result.success) { // Revoked while idle: a failed token refresh needs re-consent, not a retry. if (result.message?.startsWith('Token refresh failed')) showReconnectPrompt() return } const payload = (result.data ?? result) as Record<string, unknown> if (payload?.requiresOAuth && typeof payload.authUrl === 'string') { window.open(payload.authUrl, 'google-auth', 'width=500,height=600') // After the popup closes, refresh status and retry the call. return } // Otherwise `payload` is the upstream Google response - // e.g. for calendar-list-events, `payload.events` is the events array. ``` Apply this unwrap to every `google/*` call rather than reading `result.requiresOAuth` directly. ## Gate per feature, never on a composite Gate each UI feature on its own scope flag. A composite `isConnected` boolean that ANDs multiple scopes creates a deadlock: the user grants calendar via the connect button, status reports `calendar: true, gmailSend: false`, the composite gate stays false, the UI shows "not connected", and the user can never reach the Send button that would request `gmail.modify`. The connection state is permanently stuck. ```tsx // ❌ Deadlock - user can never satisfy the gate one grant at a time const isConnected = status?.google?.connected && status?.google?.calendar && status?.google?.gmailSend if (!isConnected) return <ConnectGoogleButton /> // ✅ Per-feature gating - calendar UI appears the moment calendar is granted; // the Gmail write scope is requested lazily when the user clicks Send. {status?.google?.calendar && <EventsList onSendRecap={attemptSend} />} async function attemptSend(to: string, subject: string, content: string) { const result = await integration.post('google/gmail-send', { to, subject, content }) if (!result.success) { if (result.message?.startsWith('Token refresh failed')) showReconnectPrompt() return } const payload = (result.data ?? result) as Record<string, unknown> if (payload?.requiresOAuth && typeof payload.authUrl === 'string') { window.open(payload.authUrl as string, 'google-auth', 'width=500,height=600') // After the popup closes, refresh status and retry the send. } } ``` The rule: render each feature whose scope is granted; on actions that need a not-yet-granted scope, attempt the call - the `requiresOAuth` response carries an `authUrl` pre-built for the missing scope set - then retry after consent. Because the platform unions newly granted scopes with existing ones, per-feature acquisition compounds correctly. ## Connection status `GET /api/integrations/status` (authenticated) returns per-scope flags so UIs can render accurate badges: ```ts { google: { connected: boolean, // a Google account is linked gmailSend: boolean, // can send mail gmailRead: boolean, // can read mail gmailModify: boolean, // can mutate the mailbox: archive, mark read/unread, trash calendar: boolean, drive: boolean, contacts: boolean, gmail: boolean, // aggregate: gmailSend || gmailRead email?: string // connected account email, when known } } ``` Broader scopes imply narrower ones: a token granted `gmail.modify` (or full mail access) reports `gmailSend` and `gmailRead` as `true` automatically, and the broad calendar/drive/contacts scopes light their flags too. The implication runs one way only - a send-only `gmail.send` token does **not** light `gmailModify` - but the platform's own consent flow never mints such a token: `google/gmail-send` requests `gmail.modify`, so consenting through the Send flow lights all three Gmail flags at once. A send-only token can only predate that flow or come from a grant made elsewhere. Gate mailbox-mutation UI (archive, mark read/unread, trash) on `gmailModify`, not on `gmail`. Use `email` for an "Acting as ..." chip without an extra round-trip. ## Disconnect `DELETE /api/integrations/oauth/google/disconnect` (authenticated) revokes and clears the current user's stored Google tokens: ```ts await fetch('/api/integrations/oauth/google/disconnect', { method: 'DELETE', headers: { Authorization: `Bearer ${await getAuthToken()}` }, }) ``` ## Testing - mock the OAuth branches Real Google round-trips are **deploy-and-manual only** - automated tests must not perform live consent flows. Mock the OAuth surface with `page.route(...)` instead. The disconnected state is the easy half: fresh test accounts always show "Connect", so a smoke spec can assert it with no mocks. The branches that fail silently in production if untested are the connected-state UI and the `requiresOAuth` recovery prompt. Minimum coverage, three recipes: ```ts // 1. Connected state renders Disconnect + data UI await page.route('**/api/integrations/status', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ google: { connected: true, calendar: true, gmailSend: true } }), }) ) await page.route('**/api/integrations/google/calendar-list-events', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { events: [{ id: 'e1', summary: 'mock event' }] }, }), }) ) // → assert the Disconnect button is visible, the mock event renders, Send is enabled // 2. requiresOAuth recovery - note the nested `data` envelope await page.route('**/api/integrations/google/gmail-send', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { requiresOAuth: true, provider: 'google', // The real payload carries the endpoint's scope set as full URLs - // for gmail-send that is gmail.modify, not gmail.send. scopes: ['https://www.googleapis.com/auth/gmail.modify'], authUrl: 'https://accounts.google.com/o/oauth2/v2/auth?mock', }, }), }) ) // → assert the reconnect prompt appears, the page does not crash, no infinite retry loop // 3. Disconnect hits the right endpoint let disconnectCalled = false await page.route('**/api/integrations/oauth/google/disconnect', (route) => { disconnectCalled = true route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true }) }) }) // → click Disconnect, assert disconnectCalled === true, the banner flips back to "Connect" ``` When real Google round-trips are not exercised, record that gap explicitly in your test report or findings notes so it is paper-trailed instead of forgotten. Mocking exactly this external boundary is the sanctioned carve-out from the no-mocking rule - see [testing](/guides/testing) - because a real call would mutate provider state and require live consent. ## Next steps * [External APIs](/guides/external-apis) - the integration client, billing routing, and discovery. * [Integrations reference](/sdk-reference/client/integrations) - envelope types and OAuth endpoints. * [Testing](/guides/testing) - the wider test discipline these mocks slot into. Source: /guides/google-oauth.md --- # LiveKit rooms Audio and video rooms through the livekit/* endpoints - token minting, room lifecycle, and reserve-then-settle billing. Audio/video - voice chat, video calls, watch-together rooms, real-time transcription - runs on LiveKit through the `livekit/*` integration endpoints. The SDK's role is deliberately narrow: it is a **room-lifecycle proxy** - create, list, and delete rooms; mint access tokens. There is no `useMediaRoom` hook and no media Durable Object class. The client-side WebRTC plumbing is yours to wire with LiveKit's own SDK. Skip this page for text-only collaborative apps - live sync, presence, and messaging need none of it. ## Install the client SDK yourself The LiveKit JS SDK is not bundled with `deepspace`. Install it as your own dependency: ```bash npm i livekit-client # or, for React-shaped components: npm i @livekit/components-react @livekit/components-styles livekit-client ``` DeepSpace handles the auth and room-lifecycle proxy; connecting, publishing tracks, and rendering participants belong to the LiveKit client API - use LiveKit's own documentation for that surface. ## The five endpoints Billing varies by endpoint - read this table before touching `src/integrations.ts`: | Endpoint | Required inputs | Returns | Billing | | ------------------------ | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `livekit/generate-token` | `roomName` (optional `displayName`, `ttlSeconds` 60-86400, default 3600) | `{ token, url, roomName }` | **Free.** The room auto-creates when the first participant connects. No participant or duration caps. | | `livekit/create-room` | `roomName` (optional `maxParticipants` 1-100 default 10, `durationMinutes` 1-1440 default 60, `metadata`) | `{ roomSid, roomName, roomSessionId, adminToken, livekitUrl, expiresAt, maxParticipants, durationMinutes }` | **Billable.** Reserves the worst case - `maxParticipants × durationMinutes × $0.0005 × 1.3` (the raw LiveKit rate times the platform's 1.3× markup) - at create time, then settles down to actual usage when you call `livekit/settle-room`. The reservation cost is derived server-side; a client cannot supply it. | | `livekit/settle-room` | `roomSessionId` (from `create-room`), `participantMinutes` (≥ 0) | `{ ok, billedParticipantMinutes }` | **Free.** Bills the reported participant-minutes clamped to the reservation cap and refunds the remainder. Idempotent per `roomSessionId`; creator-only. | | `livekit/delete-room` | `roomName` | `{ deleted, roomName }` | **Free.** Does **not** settle billing, has **no creator check**, and under the scaffold's default `developer` billing no sign-in requirement either. | | `livekit/list-rooms` | (none) | `{ rooms: [...] }` (LiveKit's Twirp `ListRooms` shape) | **Free.** | ## Two flows - pick by whether you need caps ### Ad-hoc flow (free) - the default For small group calls, low-stakes voice chat, "drop into a room" UX: Mint a token ```ts const r = await integration.post('livekit/generate-token', { roomName, displayName }) if (!r.success) throw new Error(r.error) const { token, url } = r.data as { token: string; url: string; roomName: string } ``` Connect with the LiveKit client Pass `token` and `url` to `livekit-client` (or `@livekit/components-react`). The room auto-materializes when the first participant connects and disposes itself when empty. No `create-room` call, no billing. This is the right flow unless you need participant or duration limits. ### Billable flow - rooms with quotas For paid features, large meetings, and time-limited sessions: Create the room (reserves the worst case) Call `integration.post('livekit/create-room', { roomName, maxParticipants, durationMinutes })`. This reserves `maxParticipants × durationMinutes × $0.0005 × 1.3` (raw rate times the platform markup) up front and returns an `adminToken` for the creator plus a `roomSessionId`. **Persist the `roomSessionId`** - settlement needs it. Mint per-user tokens Use `livekit/generate-token` for each participant - free; the room itself is the billed object. Settle when the session ends Call `livekit/settle-room` with the saved `roomSessionId` and the actual `participantMinutes`. Billing settles down to actual usage and the unused reservation is refunded. Optionally call `livekit/delete-room` to tear the room down. ## Settlement rules * **Always call `settle-room` when a session ends.** Skip it and the full worst-case reservation is billed: a platform cron settles abandoned cloud rooms at exactly their reservation - the cap the caller authorized at create time. * Settlement is **creator-only**: `create-room` records the owner, and no other caller can settle (or under-report) someone else's room. * Settlement is **idempotent** per `roomSessionId` - a double settle, or a settle racing the cron, nets one charge. * Reported minutes are **clamped to the reservation cap** - a client can never bill past what was reserved; under-reporting only reduces the charge. * **Self-hosted LiveKit is never metered.** With a `LIVEKIT_URL` outside `*.livekit.cloud`, the reservation is voided to zero at create time. **`delete-room` is not settlement, and it is not access-controlled.** Deleting a room does not release the `create-room` reservation - only `settle-room` does that. And any caller can delete any room by name: the platform performs no creator check, and because LiveKit is not pre-listed in `src/integrations.ts`, the scaffold's default `developer` billing applies - whose sign-in gate never fires - so even an anonymous visitor reaches it. Application-layer gating is mandatory: gate the "End meeting" control on the creator, e.g. `useUser().user?.id === room.createdBy`, or store the creator in your own collection and check it before calling. ## Auth-gate the token-minting page A leaked `generate-token` token grants room access until `ttlSeconds` expires (default one hour) - there is no revocation. Always auth-gate the page or component that mints tokens: wrap it in `useAuth().isSignedIn`, or place it behind `<AuthGate>` when the whole page is gated. For the billable flow, gate `create-room` - under the default `developer` billing an anonymous visitor can commit billable reservations against the app owner - and gate `delete-room` per the warning above. `settle-room` needs no extra gating - it is creator-only and refund-only. ## Next steps * [External APIs](/guides/external-apis) - the `integration.post(...)` client, billing modes, and discovery. * [Integrations reference](/sdk-reference/client/integrations) - envelope types and request options. * [Realtime rooms](/sdk-reference/worker/rooms) - the SDK's own Durable Object rooms, which handle data sync, not media. Source: /guides/livekit.md --- # Custom bindings Declare Vectorize, R2, KV, D1, Queues, and other Cloudflare resources for your app. DeepSpace deploys to Cloudflare Workers for Platforms, which means your app can use the full Cloudflare resource catalog. Declare bindings in `wrangler.toml` the same way you would for a stand-alone worker; set the ID to `"auto"` and the deploy worker provisions the resource on your first deploy. ## Declaring bindings Add entries to `wrangler.toml`: ```toml # Vectorize [[vectorize]] binding = "VEC" index_name = "auto" dimensions = 768 metric = "cosine" # R2 [[r2_buckets]] binding = "FILES" bucket_name = "auto" # KV [[kv_namespaces]] binding = "CACHE" id = "auto" title = "my-cache" # D1 [[d1_databases]] binding = "MY_DB" database_id = "auto" database_name = "my-app-db" # Queue producer [[queues.producers]] binding = "MAILER" queue = "auto" # Workers AI (no provisioning needed) [ai] binding = "AI" # Browser Rendering [browser] binding = "BROWSER" # Hyperdrive (cannot use "auto" - provision in CF dashboard first) [[hyperdrive]] binding = "PG" id = "<your-hyperdrive-config-id>" # Analytics Engine (rare; USAGE_EVENTS is auto-attached) [[analytics_engine_datasets]] binding = "EVENTS" dataset = "my_events" ``` Allowed binding types: `vectorize`, `ai`, `r2_bucket`, `kv_namespace`, `d1`, `queue`, `browser_rendering`, `analytics_engine`, `hyperdrive`. The deploy validator rejects anything else. For standard preview / OG-image flows on `*.app.space` / `*.deep.space` URLs you do **not** need a `[browser]` binding - call [`captureScreenshot`](/sdk-reference/worker/bindings#shared-browser-rendering-capturescreenshot) from `deepspace/server` to render through the platform's shared Browser Rendering binding. Declare your own only for unmetered usage, custom user agents, or third-party hosts. ## `"auto"` autoprovisioning When the ID field is the literal string `"auto"`, the deploy worker creates the resource on the platform Cloudflare account on first deploy, persists the ID, and reuses it on subsequent deploys. | Type | Sentinel field | Required companion | CF-side name | | -------------- | ---------------------- | ---------------------- | --------------------------------------- | | `d1` | `database_id = "auto"` | `database_name` | The `database_name` you supplied | | `kv_namespace` | `id = "auto"` | `title` | The `title` you supplied | | `vectorize` | `index_name = "auto"` | `dimensions`, `metric` | `app-<appName>-<binding.toLowerCase()>` | | `r2_bucket` | `bucket_name = "auto"` | - | `app-<appName>-<binding.toLowerCase()>` | | `queue` | `queue = "auto"` | - | `app-<appName>-<binding.toLowerCase()>` | | `hyperdrive` | not supported | - | - | Vectorize / R2 / Queue resources get an `app-<appName>-` prefix to avoid collisions across apps. D1 and KV use the supplied name verbatim - choose unique names yourself. Provisioned IDs persist in `app-resources/<appName>.json` on the platform R2 bucket. If the registry is missing but the resource exists on CF (e.g., a prior failed deploy), the deploy worker adopts on conflict - it looks up the resource by name and writes the ID back into the registry. ## Reserved names Eleven binding names are owned by the SDK and cannot be redeclared: ``` ASSETS, PLATFORM_WORKER, API_WORKER, APP_NAME, OWNER_USER_ID, AUTH_JWT_PUBLIC_KEY, AUTH_JWT_ISSUER, AUTH_WORKER_URL, APP_IDENTITY_TOKEN, APP_OWNER_JWT, USAGE_EVENTS ``` The deploy validator rejects custom bindings using any of these names. It does **not** check against your `__DO_MANIFEST__` DO class names - picking `RECORD_ROOMS` as a custom binding name fails silently at deploy and shadows the DO at runtime. ## D1 bootstrap - `runMigrations` Auto-provisioned D1 starts empty. Use `runMigrations` to create your tables idempotently on worker startup: ```ts import { runMigrations } from 'deepspace/worker' // Inside fetch handler or at module init: await runMigrations(env.MY_DB, [ `CREATE TABLE notes ( id TEXT PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_notes_created ON notes(created_at);`, `ALTER TABLE notes ADD COLUMN tags TEXT;`, ]) ``` Contract: * Each array entry is one migration; the runner tracks applied indexes in a `_dpc_migrations` meta-table. * Each migration string can contain multiple `;`-separated statements. **Don't put `;` inside string literals** - the split is naive. * Idempotent: re-running with the same array is a no-op. * **Append new migrations to the end. Never reorder or delete entries.** * Returns `{ fromVersion, toVersion, applied }`. * Throws on any individual migration failure; the failed row is not inserted, so the next deploy retries. ## Per-tenant metering Every deployed app gets a `USAGE_EVENTS` Analytics Engine binding automatically. Use the metering helpers to roll cost up per app owner: ```ts import { meterAi, meterVectorize, meterUsage } from 'deepspace/worker' // Workers AI usage meterAi(env, '@cf/meta/llama-3.1-8b', { inputChars, outputChars }) // Vectorize usage meterVectorize(env, 'docs', 'query', { vectors: 1, dims: 768, storedCount }) // Generic fallback (Browser Rendering, Hyperdrive, custom kinds) meterUsage(env, 'browser', { id: 'render', units: 1, count: 1 }) ``` Each helper returns `boolean` - `false` when `USAGE_EVENTS` is absent or AnalyticsEngine throws. **Metering never breaks the calling code path.** Cost rollup multipliers live in `COST_RATES` (exported from `deepspace/worker`). ## Undeploy `npx deepspace app undeploy` removes the worker and cleans up resources: | Resource | Cleanup | | ---------------------------- | ------------------------------------------------------------------- | | D1 / KV / Vectorize / Queues | Deleted via CF API; registry updated | | **R2 buckets** | Deleted only if empty (preserves user uploads on non-empty buckets) | | Hyperdrive | Never auto-deleted (never auto-provisioned) | ## Pitfalls Vectorize dimension changes aren't caught at deploy If you change `dimensions` from 768 → 1536 after the index was created, the adoption succeeds and the failure surfaces at first-vector-insert at runtime. Delete the index and redeploy if you really need to change shape. \`auto\` is platform-side only Local dev does not provision auto resources. The binding only resolves at `npx deepspace deploy`. For local dev against a real CF resource, point the binding at a manually-created resource by ID. User-secret name collisions Secret names cannot collide with custom-binding names or DO class names. The deploy worker rejects with 400 before forwarding to Workers for Platforms. R2 + Vectorize CF-side names differ from binding names When debugging in the CF dashboard, look for `app-<appName>-<bindingLower>`. The friendly binding name is only what your worker sees in `env.VEC` etc. ## Next steps * [Worker bindings reference](/sdk-reference/worker/bindings) - full type signatures for `runMigrations`, `meter*`, manifest exports. * [Custom domains](/guides/custom-domains) - attach a real domain to your app. * [Deployment](/concepts/deployment) - what happens during a deploy. Source: /guides/custom-bindings.md --- # Testing Playwright specs, the multi-user fixture, and how to test against real services. Every scaffolded app ships with Playwright tests in `tests/`. The CLI's `test` command bootstraps Playwright (downloads Chromium on first run), regenerates dev secrets, and runs the suite against the dev workers. Tests use **real services**: app-internal hooks, routes, and services stay real, always. The one sanctioned carve-out is the **external** integration boundary: mock exactly the paid or user-OAuth integration call when a real call would charge money, mutate provider state, or require credits or credentials the test run doesn't have. Nothing inside your app qualifies - if a piece of your own code is hard to exercise, that's a design problem to fix, not a seam to mock. ## Three spec files | File | Covers | | ---------------- | ----------------------------------------------------------------- | | `smoke.spec.ts` | App boots, navigation renders, page titles, auth UI present | | `api.spec.ts` | API routes return expected shapes; auth gating; integration calls | | `collab.spec.ts` | Multi-user real-time sync - two users connect and see each other | Installing a feature (`docs`, `kanban`, `messaging`, …) does not add a new spec file. Extend these three. ## Running tests ```bash # Default - smoke + api npx deepspace test run # All Playwright specs npx deepspace test run e2e # Subset npx deepspace test run smoke npx deepspace test run api npx deepspace test run tests/checkout.spec.ts # Vitest unit tests npx deepspace test run unit # Match a parallel dev server port npx deepspace test run --port 5180 # Plain Playwright (skips .dev.vars regen - useful for iterating) npx playwright test npx playwright test --ui ``` No separate dev server is required - the scaffolded `tests/playwright.config.ts` starts Vite if it's not already running and reuses it if it is. ## Multi-user testing - the `users` fixture The SDK ships a Playwright fixture from `'deepspace/testing'` that returns N signed-in browser contexts: ```ts import { test, expect } from 'deepspace/testing' test('A sends, B sees', async ({ users }) => { const [alice, bob] = await users(2) await alice.page.goto('/chat') await bob.page.goto('/chat') await alice.page.getByTestId('send-btn').click() await expect(bob.page.getByText('hi')).toBeVisible() }) ``` Each `MultiplayerUser` is `{ context, page, email, name, userId? }`. Contexts auto-close when the test finishes. The fixture caches `storageState` per account, so each test account signs in once per machine - not once per test. This sidesteps Better Auth's per-IP rate limit on `/api/auth/sign-in/email` and is materially faster as the suite grows. Pick specific accounts by name: ```ts const [alice, bob] = await users(['Alice', 'Bob']) ``` ## Provisioning test accounts The fixture reads from `~/.deepspace/test-accounts.json` - a local credential store written mode 0600. Credentials live there and nowhere else: don't copy passwords into specs, fixtures, or committed files. Populate it via the CLI: ```bash # Check what you already have npx deepspace test accounts list # ...only the ones the users() fixture can actually sign in (saved credentials) npx deepspace test accounts list --usable # Create new accounts as needed (max 10 total per machine) npx deepspace test accounts create --email alice-1@deepspace.test --password Pass123! --name "Alice" npx deepspace test accounts create --email bob-1@deepspace.test --password Pass123! --name "Bob" ``` Each account prints a **`Selector:`** - the string you pass to `users(['Selector'])`. It is the `--name` you gave, or the email's local part when you omit `--name`, so every created account is selectable. `list` shows a `usableByFixture` flag (in `--json`) and masks saved passwords in human output; pass `--reveal` to print them - in `--json` too, where the `password` field is omitted entirely without the flag - or `--usable` to list only accounts whose credentials are saved locally - the ones `users()` can actually drive. An account visible remotely but without saved credentials on this machine is **not** usable by the fixture, which is exactly what `--usable` filters for. The account pool is global per developer and shared across apps. Emails must end `@deepspace.test`. **Don't bake the app name into the email** - the same accounts work for every app. **Cap is 10 accounts per machine.** Reuse what you have. If `collab.spec.ts` ships with `await users(['Collab A', 'Collab B'])` and your pool doesn't have those exact names, change the call to `await users(2)` to grab the first N accounts by `createdAt` regardless of name. The pool is shared across every app on the machine, so treat it as shared infrastructure: create only the shortfall a run actually needs, and delete only accounts **created for the current run**. Never clear the pool wholesale - other apps' suites depend on the accounts already in it. Creating them requires an OAuth-authenticated developer session - a test account cannot create more test accounts. ### What test accounts cannot do Test accounts sign in with an email and a password - exactly what a Playwright fixture needs, and exactly what real DeepSpace accounts don't have. Those sign in through [browser OAuth](/cli-reference/overview#login-state) only. The trade is that a test account is a fixture, not a customer. On the `test` billing tier it: * **cannot deploy apps** - the platform refuses with `test_account_cannot_deploy`, and the account's app quota is 0 * **has zero storage quota** - no Git packs, rollback bundles, or deploy assets * **cannot be a [collaborator](/guides/collaborators)**, in either direction * **cannot receive an app transfer, or request credits** Use them to drive multi-user auth flows in your app. Everything that touches the platform itself stays on your own account. ## The test extension checklist Run tests only after a runtime-affecting code change (`src/`, `worker.ts`, etc.). Skip them for conversation, planning, or pure documentation edits. | Trigger | Required test | | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | Added a schema | `smoke.spec.ts` - CRUD happy path for a signed-in user | | Added/edited a route, page, nav item, or top-level UI | `smoke.spec.ts` - page-load with real-content assertion | | Schema with `visibilityField` or `'public'/'shared'/'team'/'own'` permissions | `collab.spec.ts` - two-user assertion (A acts, B sees) | | Used `useYjs*` / `useMessages` / `useReactions` / `usePresence` / `useCanvas` | `collab.spec.ts` - two-user assertion | | Added/edited worker route, server action, AI chat, cron, integration call, or auth-gated UI | `api.spec.ts` - status codes + shape + auth gating | | Fixing a bug | Write a failing test first, then fix. Leave the test in place. | For integration calls specifically, POST to `/api/integrations/<endpoint>` and assert `success: true` with the data shape your UI consumes. This catches wrong endpoint names - the most common integration-heavy-app failure. ## Test data cleanup Tests run against the same local Durable Object the dev server uses, so anything you create persists. Two conventions to keep the dev DB clean: Prefix test records Every record a test creates should start with `__test-${Date.now()}__` in its human-visible field (title, name, question). Clean up in afterEach / afterAll Track created `recordId`s and delete them after the test. Don't add a blanket "wipe the DB" step - it would destroy real dev data. ```ts test('user A posts a message', async ({ users }) => { const [alice] = await users(1) const created: string[] = [] try { const title = `__test-${Date.now()}__ Hello` // ... create, capture recordId ... } finally { for (const id of created.reverse()) { try { /* delete via your endpoint */ } catch { /* swallow */ } } } }) ``` ## Auth-state assertions Every route lives in one of three tiers, and each tier has its own contract to assert: | Route tier | Smoke assertion | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Static (`src/pages/<name>.tsx`) | Signed-out visitor sees real content with **no providers mounted** - no auth session fetch, no realtime WebSocket, no `[data-testid="auth-overlay"]`. The top-level landing page must preserve this static contract. | | Dynamic (`src/pages/(app)/<name>.tsx`) | Signed-out visitor sees real, dynamic content; `[data-testid="auth-overlay"]` count is `0`. Providers are mounted, but the page is not gated. | | Gated (`src/pages/(app)/(protected)/<name>.tsx`) | Signed-out: overlay visible **and** protected content not in DOM. Signed-in: content visible, no overlay. | | After sign-out from gated | URL navigates to `redirectOnSignOut` (default `/`). Overlay does not appear - a stranded overlay is a bug. | The `[data-testid="auth-overlay"]` attribute is on the SDK's `<AuthOverlay/>` - more reliable than text matching. `data-testid="app-root"` is the canonical "app shell mounted" hook, present on every page - static and dynamic - via the scaffold's `_app.tsx`. Anchor shell-level waits on it, and **don't rename it**: templates and tests both depend on the exact string. ## Route coverage Every reachable route must have a test that: 1. Navigates to it (for dynamic routes, create a record first and use its ID) 2. Waits for real content to appear (a specific element with real data - not just "no crash") 3. Fails loudly on empty/not-found states when there shouldn't be one ```ts test('/polls/:id renders the question', async ({ page }) => { const id = await createTestPoll('Favorite color?') await page.goto(`/polls/${id}`) await expect(page.getByTestId('poll-question')).toContainText('Favorite color?') }) ``` A "page loads without JS errors" assertion is **not** sufficient. Assert that the data that should be there *is* there. ## Testing `canWrite`-gated UI Surfaces backed by `useYjsRoom`, `useYjsText`, `useYjsField`, `useCanvas`, `useCronMonitor`, and `useJobs` all expose a `canWrite` boolean that defaults to `false` until the server's AUTH frame arrives. Two patterns matter for tests: **Don't use `getByRole('textbox')` on ProseMirror / Tiptap editors.** A page that also renders a title `<input>` has multiple textbox-role nodes and the locator is ambiguous. Target the editable surface directly with a stable `data-testid`: ```ts const editor = page.locator('[data-testid="editor-content"] .ProseMirror') ``` **Don't use `expect(locator).toBeEditable()`.** Playwright's actionability poll runs busy enough to starve the WebSocket `onmessage` callback, so the AUTH frame never lands and `contenteditable` stays `"false"`. Poll the attribute passively instead: ```ts // Writer (member / owner) - wait for canWrite to flip true await expect.poll( () => editor.getAttribute('contenteditable'), { timeout: 30_000, intervals: [500] }, ).toBe('true') // Viewer - assert it stays read-only await expect.poll( () => editor.getAttribute('contenteditable'), { timeout: 30_000, intervals: [500] }, ).toBe('false') ``` The same race applies to any `canWrite`-gated UI - if a test wants to assert the writer can act before clicking, poll a DOM signal (a `disabled` attribute, `aria-readonly`, `data-can-write="true"`) rather than relying on actionability checks. ## Self-diagnosis with tests When something isn't working, don't start with console logs. Start with: Write or tighten a test that expresses the expected behavior Describe the assertion you'd run if the feature worked. Run it Read the failure message and the failing selector or assertion. Fix the code until the test passes The test tells you what was expected and what was observed. Leave the test in place It now guards against regression. A failing test tells you more than a log ever will: what was expected, what was observed, where in the flow it diverged. ## Screenshots for visual debugging ```bash npx deepspace test screenshot http://localhost:5173/ out.png npx deepspace test screenshot http://localhost:5173/dashboard out.png --full-page npx deepspace test screenshot http://localhost:5173/ mobile.png --viewport 390x844 npx deepspace test screenshot http://localhost:5173/ out.png --wait-for-timeout 500 ``` Shares the same Chromium install as `test`. Use it for "what does this page actually render right now" workflows - not as a substitute for Playwright assertions. ## Tips * **Re-run after every follow-up change.** Apply the extension checklist each turn - tests are a living contract. * **Don't weaken tests to make them green.** Write a more specific assertion, or fix the underlying behavior. * **Avoid `console.log`-driven debugging.** A tighter assertion gives better signal than a log ever will. ## Next steps * [Testing reference](/sdk-reference/testing) - `users` fixture, `loadAllTestAccounts`, `ensureStorageState`. * [CLI test command](/cli-reference/commands#test) - flags and environment variables. Source: /guides/testing.md --- # Secrets The per-app encrypted secrets store: commands, configs, caps, propagation, and troubleshooting. Every app has exactly **one** platform-owned, encrypted secrets store, keyed by the immutable `DEEPSPACE_APP_ID` in `wrangler.toml`. There is no setup or link step: run the commands from the app directory (or pass `--app <appId>`) and they work - for the owner and [collaborators](/guides/collaborators) alike, even before the first deploy. The first write registers the app id to you. The store is the source of truth for **every** environment: * `.dev.vars` is a generated plaintext materialization of the store, written mode `0600`. `dev start`, `test run`, `deploy`, and `secrets pull` rewrite the file **whole**. Never commit it, and never add or edit app secrets in it - hand edits disappear on the next write. * Deploy never reads `.dev.vars`. The store is the only deploy input: deploy binds each store secret as a Cloudflare `secret_text` binding and reconciles the worker's bindings against the store. * Worker code reads `env.API_KEY` - identical in dev and after deploy. ## Bootstrap: there is none ```bash npx deepspace secrets set API_KEY=sk_live_... # works even pre-deploy npx deepspace dev start # regenerates the cache; the worker sees env.API_KEY ``` Two propagation rules the CLI reminds you of: * A **deployed** app picks up store changes only at the next `deploy` - bindings are set at deploy time, never fetched at runtime. * A **running** dev session picks them up only on restart - the cache regenerates at startup, not mid-session. `secrets pull` refreshes the file without running dev. ## Commands ```bash npx deepspace secrets list # masked: name, version, updated; --only-names, --json npx deepspace secrets set API_KEY=sk_... B=2 # one or more KEY=value pairs; multiline/PEM values fine npx deepspace secrets get API_KEY --plain # byte-exact when piped (> key.pem) npx deepspace secrets delete API_KEY OLD_KEY # idempotent - already-absent keys tolerated npx deepspace secrets pull # refresh the .dev.vars cache without running dev npx deepspace secrets download --format json # stdout only; dotenv (default) | json | shell npx deepspace secrets upload .env [--replace] # dotenv or JSON, `-` for stdin; --replace deletes keys absent from the file npx deepspace secrets configs list npx deepspace secrets configs create qa --copy-from prd # server-side copy npx deepspace secrets configs delete qa ``` `set`, `upload`, and `delete` change the **remote store only** - a running dev session keeps its old values until restarted. Each of the three says so in both surfaces: human output prints `Run \`deepspace deploy\` to apply`, and `--json`carries the same fact as`"appliesAtDeploy": true\`, so a script cannot mistake a successful store write for a live change. ### Targeting flags Every subcommand takes: | Flag | Meaning | Default | | ----------------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | | `-a`, `--app <appId>` | Which app's store | `DEEPSPACE_APP_ID` from the nearest `wrangler.toml` | | `-c`, `--config <name>` | Which config within that store | `prd`, or the `--env` name | | `-e`, `--env <name>` | Target the `[env.<name>]` block - a **separate app** with its own store | - | `-e` and `-c` are different axes: `-e staging` addresses the staging *app's* store (config defaulting to `staging`), while `-c staging` addresses another config of the *current* app. Mixing them up is caught - `-e staging` without an `[env.staging]` app id errors and points you at `-c staging`. ## Names and caps Secret names match `[A-Za-z_][A-Za-z0-9_]*`, conventionally `UPPER_SNAKE`. Colliding names are rejected client-side with a clear message before any upload: every SDK-reserved binding name (`DEEPSPACE_APP_ID`, `APP_OWNER_JWT`, `AUTH_JWT_PUBLIC_KEY`, `ASSETS`, ...) plus `API_WORKER_URL` and `PLATFORM_WORKER_URL`, and any custom or Durable Object binding name declared in `wrangler.toml`. Config names match `[A-Za-z0-9][A-Za-z0-9_-]{0,63}`. | Cap | Value | | ---------------------------- | ------ | | Per-value size | 32 KB | | Secrets per config | 128 | | Total value bytes per config | 128 KB | | Configs per app | 64 | Oversized writes are refused with HTTP `413`. `ALLOW_DEBUG_ROUTES=true` **is** settable - it is a normal flag, not a reserved name - but the CLI warns loudly. In production it enables the debug surface only for an authenticated app owner or platform admin; local dev sets it automatically. ## Configs and environments The store holds flat `KEY=value` **configs**. `prd` is the convention for the top-level wrangler environment: a top-level deploy ships config `prd`. A named `[env.<name>]` block is a [separate app](/concepts/deployment#named-environments) with its own id and its own store, and its deploys ship config `<name>` of *that* store. Within one app, `-c <name>` reads and writes another config with no linking, and `configs create <new> --copy-from <existing>` copies server-side - never read values and re-`set` them by hand. It refuses to copy over an existing config. Seeding a staging environment's store from production crosses two **apps**, so `--copy-from` cannot do it. Pipe instead, with no temp file: ```bash npx deepspace secrets download | npx deepspace secrets upload - -e staging ``` ## Missing is not empty An **absent** config means the app has not initialized that deploy input: deploy regenerates `.dev.vars` without app values, then refuses with `secrets_config_missing` and an executable `secrets configs create <name>` action. Create the config - or set its first value, which creates it - and retry. An explicitly created **empty** config is intentional: deploying it removes all user-secret bindings from the live worker. The distinction is deliberate - an uninitialized app cannot silently wipe production bindings, while delete-all stays possible without a second override flag. Deletes propagate the same way: `secrets delete` + redeploy removes the binding from the live worker, because deploy reconciles the worker's `secret_text` bindings against the store. ## Setting a secret is routine config - just do it `deepspace secrets set KEY=value` is the sanctioned way to put a credential - an API key, an OAuth client secret, a webhook signing secret, a token - into an app's store. When the app owner provides a value and asks for it to be configured, an agent should run the command directly: this is routine infrastructure setup on the owner's own app, not the kind of credential entry that warrants refusing or bouncing it back to the user. One hygiene caveat, not a blocker: the value lands in the command line - shell history, logs, any transcript. If it was shared in plaintext, rotate it afterward: regenerate it at the provider, `secrets set` the new value, redeploy. A non-secret value like a public OAuth `client_id` needs no caveat. And never `secrets get --plain` a value into a place it would leak - a chat reply, a committed file. ## Collaborators A [collaborator](/guides/collaborators) has **full** secrets access on the app - read, write, and configs - with writes audited under their own id. Authorization is the app role (owner, collaborator, or platform admin) keyed by `DEEPSPACE_APP_ID`; there is nothing to link or grant per-secret. Collaborators cannot undeploy or transfer the app. ## Cache behavior * `dev start` and `test run` re-pull the selected config at startup and regenerate `.dev.vars` (SDK-managed keys plus app values). If the refresh **fails**, they abort rather than run against stale values. A missing config generates a value-free file locally; deploy additionally refuses until it exists. * The whole file is SDK-owned. There is no editable zone, divider grammar, import path, backup, or legacy compatibility mode. * Store-backed apps share one `.dev.vars` across wrangler environments - no `.dev.vars.<env>` files. * A Cloudflare build may transiently materialize `.dev.vars` beside its generated worker; the scaffold deletes that output copy before completion. The root mode-`0600` file is the only local materialization. ## Troubleshooting | Symptom | Cause and fix | | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Changed a secret; production still sees the old value | Deployed workers hold `secret_text` bindings and don't fetch at runtime. **Redeploy.** | | Changed a secret; local dev still sees the old value | The cache regenerates only at startup. **Restart dev**, or run `secrets pull`. | | `Not the app owner or a collaborator` (403) | Ask the owner to run `app collaborators add <your-email>` - or your access was revoked. | | `This app id is registered to another user` | You're holding someone else's id (a cloned repo). `npx deepspace app init --new-id` forks it into your own app with a fresh store. | | `list` shows nothing on a fresh app or config | Legitimate - the first `set` creates the store. Reads are side-effect-free and never register anything. | | Name rejected | Match `[A-Za-z_][A-Za-z0-9_]*` and avoid the platform-injected names above. | | A `DeepSpace detected secrets` comment in `wrangler.toml` disagrees with reality | It's a static scaffold placeholder the CLI does not maintain. `secrets list` is the truth. | | Ran `app undeploy` - are the secrets gone? | No. Undeploy keeps the store; redeploying the same app id revives the same secrets. | ## Next steps * [Deployment](/concepts/deployment) - how the store becomes live `secret_text` bindings, and named environments. * [Collaborators](/guides/collaborators) - who can read and write the store. * [Command reference](/cli-reference/commands#secrets) - the flag tables. Source: /guides/secrets.md --- # Releases and rollback The append-only release ledger, what a deploy records under each source mode, and how rollback works. Every deploy appends a **release fact** to an append-only ledger - even a byte-identical redeploy. `npx deepspace releases` reads the ledger; `npx deepspace rollback` re-ships an earlier entry. Nothing in the ledger is ever edited or removed, which is what makes "what is live, and what was live before it" a question with a reliable answer. ```bash npx deepspace releases # newest first, default 20; --limit N npx deepspace releases --json # same ledger, machine-readable npx deepspace rollback # defaults to the previous release npx deepspace rollback rel_... # a specific release id npx deepspace rollback rel_... --allow-do-deletion ``` ## What a release records depends on the source Release semantics follow the app's [source mode](/guides/source-control) - the release-record delta: * **DeepSpace source** (commit-first): the release records the deployed commit, so the ledger doubles as source lineage - every release maps to an exact commit - and the workspace, ancestry, and stale-base guards below apply. * **GitHub source** (ships the working tree): the release records `commitOid: null` and retains the recorded repository (claimed, or observed from the checkout) and source revision as metadata. `--no-push` skips source sync for one deploy - shipping without commit lineage - and never changes what owns the source; see [source control](/guides/source-control#app-source-is-read-only). Every successful `deploy --json` therefore also reports **what tree it shipped**, as `branch` and `dirty` — and **what authority it shipped from**, as `source: { provider, repository?, inferred? } | null` (the base shape `releases --json` uses; `inferred: true` is deploy-envelope-only and marks unclaimed-GitHub evidence — release rows never carry it). Under DeepSpace source those restate a guarded invariant (a dirty worktree is refused outright, and `commitOid` pins the code). Under GitHub source they are the only record there is: that path ships the working tree from whatever branch you are on, uncommitted edits included, and records no commit. So a GitHub-source deploy also says it on stderr before shipping - an informational line when the tree is clean, and a **warning** when it is dirty, because nothing afterwards can reconstruct what went live. Both are `null` when the app directory is not a usable Git repository, which is a fact rather than a failure: the deploy still ships. ## How a release names its source `releases`, `status`, and `activity` describe a release's source with one shared formatter, so the three can no longer disagree about the same release: | Human output | When | | -------------------------------------- | --------------------------------------------------------------- | | `commit <first 10 chars>` | The release recorded a commit (DeepSpace source, normal path) | | `GitHub · owner/repo` | GitHub source - no commit, but the repository is known | | `DeepSpace source, no commit recorded` | DeepSpace source that recorded no commit (a `--no-push` deploy) | | `no source recorded` | Genuinely no source information | Previously a GitHub-source release printed "no source recorded" in human output while its `--json` row carried the repository - the human surface was simply wrong about a release whose source was known. ## Rollback re-ships a retained bundle Rollback does **not** rebuild and does not touch Git. It re-ships the target release's stored bundle exactly as deployed, and appends another release fact - history moves forward even when the code moves back, so the ledger records the rollback itself. Bundles are retained for rollback, but not forever: storage pressure can evict an old bundle while its ledger row remains. The releases listing marks every entry's rollback availability (`rollbackAvailable` in `--json`); a rollback against an evicted bundle refuses with `no_bundle` - choose another retained release. **`--allow-do-deletion` deletes data.** When the target release declares fewer Durable Object classes than the current one, rollback refuses, because completing it permanently deletes those classes' stored data. Before using the flag, spell out exactly which classes disappear and what data that destroys, and get the app owner's explicit approval. This is a data-loss decision, not a confirmation to click through. ## The refusal taxonomy Deploy and rollback refuse with stable codes. Branch on the code, never the prose. | Code | What it means | The fix | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dirty_worktree` | A DeepSpace-source deploy (sync on) found uncommitted changes. A deploy records the commit it ships, and there is none to record. | Commit - WIP commits are fine - and re-run. | | `behind_trunk` | Your branch is strictly behind the cloud repo: deploying would take already-landed work off the live app. | `npx deepspace pull`, then redeploy. `--ignore-stale` ships the older tree anyway - the live app reverts until the next up-to-date deploy. | | `stale_base` | The deploy can't be proven to contain the live release. Three causes share the code: a newer release landed while you worked; the commit being deployed was never synced to the cloud repo (auto-push failed, or `--no-push` with an unpushed commit); or - GitHub source - the live release changed after source verification. | The refusal's message names the recovery: `npx deepspace push` when the commit never synced; pull, integrate, and redeploy when a newer release landed; re-verify and redeploy on GitHub source. `--ignore-stale` skips the guard - only when replacing the live release is the intent. | | `no_bundle` | The target release's bundle was evicted or is unreadable; only its ledger row remains. | Roll back to a different release - `releases --json` marks which are `rollbackAvailable`. | | `workspace_unsynced` | A deploy from a [workspace](/guides/workspaces) whose exact HEAD has not been published with `workspace sync`. | Run the refusal's `workspace sync` action, then redeploy. | | `rename_required` | The `name` in `wrangler.toml` no longer matches the host the registry serves, so this deploy would move the app's URL - and there is no terminal to confirm on. | Two different intents, which is why the refusal ships **no** executable action: re-run with `--rename` to approve the move, or `npx deepspace app init --new-id` if you meant a separate app. The refusal also states what a rename does not carry - see [renaming an app](/guides/app-identity#renaming-an-app). | | `owner_jwt_missing` | A collaborator (or platform admin) deploying an app the **owner** has never deployed. There is no live version carrying an `APP_OWNER_JWT`, so the platform has nothing to inherit the existing secrets from. Raised before the commit, not after a partial ship. | Ask the owner to deploy once. Nothing the collaborator can do locally fixes it. | | `merge_in_progress` | The worktree is mid-merge, -rebase, -cherry-pick, or -revert. `HEAD` is the pre-operation commit, so the deploy (or `push`/`pull`, which share the guard) would ship a tree carrying none of the in-flight work. | Finish (`git merge --continue` and friends) or abort, then retry. Two remedies, so no single action ships. | | `deploy_in_progress` | Another deploy of **this checkout** holds the local lock at `.deepspace/deploy.lock` - two deploys of one directory race on `dist/`. The refusal names the holder's pid and start time. A lock whose process is gone is reclaimed automatically. | Wait for it to finish. Remove the lock by hand only when no deploy is running. | | `release_in_progress` (exit **2**) | Another deploy of the same app, from another checkout, is between prepared and live. This run built and uploaded but did not release; nothing is wrong and nothing needs changing. | Run the returned action - the same deploy again - after a moment. If it keeps refusing for more than a couple of minutes, look at `deepspace releases`. | | `forbidden` | The app belongs to another account. The sentence names the app id and the account you are signed in as. | Have the owner run `app collaborators add <your email>`, log in as the owner, or `app init --new-id` to publish the code as your own app. | The table covers the lineage and concurrency refusals, not every code: rollback's Durable-Object guard refuses with `do_class_verify_failed` / `do_class_deletion` (the codes behind `--allow-do-deletion` above), and guarded source operations carry their own set - see [the workspace refusal contract](/guides/workspaces#the-refusal-contract). Build and secrets refusals (`secrets_config_missing`, reserved-name and binding collisions) are deploy mechanics rather than lineage - see [Deployment](/concepts/deployment) and the [secrets guide](/guides/secrets). This ledger is your app's release history. For the SDK's own version history - what changed in each `deepspace` release - see the [changelog](https://deep.space/changelog). ## Next steps * [Deployment](/concepts/deployment) - what a deploy does, and when it is actually live. * [Command reference](/cli-reference/commands#releases) - flags for `releases`, `rollback`, and `deploy`. * [Collaborators](/guides/collaborators) - who can ship and roll back. * [Changelog](https://deep.space/changelog) - what shipped in each SDK release. Source: /guides/releases-and-rollback.md --- # App identity The immutable app id, name leases, renames, undeploy, and ownership transfer. Every app has an **immutable id** — `app_` followed by 26 characters — minted when the app is created. It lives in `wrangler.toml`: ```toml [vars] DEEPSPACE_APP_ID = "app_01HZXYABCDEFGHJKMNPQRSTVWX" ``` The id **is** the app's durable identity. Data, secrets, collaborators, billing, and custom domains all address the app through it. For an identity-migrated app, the backend maps the id to a permanent private `resourceId` that still owns the physical stores — you never handle the `resourceId` directly; the app id is the one identity you work with. **Commit `wrangler.toml`.** The app id is not a secret — it is the authorization *key*, not a credential. A collaborator who clones the repo gets working `dev`, `test`, `deploy`, and `secrets` access from the id alone, resolved against their own sign-in. ## Id versus name The `name` field in `wrangler.toml` is only a **lease** on `<name>.app.space`. It can change without the app losing anything: the id stays, and everything keyed to the id travels along. Treat names as URL labels; treat the id as the app. In scripts and automation, always prefer the id. A name can be renamed out from under a script; the id cannot. ## Where ids come from There are exactly three mints, and they all end in the same place — `DEEPSPACE_APP_ID` in `wrangler.toml`: * **Scaffold** — `npm create deepspace` mints one into the new project. * **First deploy** — `npx deepspace deploy` in a repo without an id mints one on the spot and writes it to `wrangler.toml`. Commit that change; it is the app's identity record. * **Explicit init** — `npx deepspace app init` stamps an id into an existing repo without deploying. ## Forking with `--new-id` ```bash npx deepspace app init --new-id ``` `--new-id` writes a fresh id for a fork: same code, separate data, separate secrets store. Use it when a cloned repo still identifies someone else's app — the tell is a "registered to another user" error on `secrets` or `deploy`. `--new-id` does **not** change Wrangler's `name` or reserve a URL. Pick the fork's own name before its first deploy, or the deploy will try to claim the original's lease. ## Environments are separate apps Each `[env.<name>]` block in `wrangler.toml` is its **own app** with its own id, its own Durable Objects, and its own secrets store. Mint that identity with `npx deepspace app init --env <name>`, or let the environment's first deploy mint it. Remove one with `npx deepspace app undeploy --env <name>`. ## Renaming an app Change `name` in `wrangler.toml` and deploy. The CLI asks you to confirm the rename (or pass `--rename` to pre-confirm): ```bash npx deepspace deploy --rename ``` The new URL serves immediately and the old one stops. Data, secrets, collaborators, and custom domains follow the id, untouched. In particular, an attached [custom domain](/guides/custom-domains) keeps routing without any re-attach. **The display name does not travel.** A rename moves the URL; it does not touch the human-readable name your app renders, which lives in two places you edit yourself: * `src/constants.ts` → `APP_NAME` * `wrangler.toml` → `[vars].APP_NAME` Update both and redeploy, or the renamed app keeps showing its old name to users. Every surface says so now: the interactive confirmation, the non-interactive `rename_required` refusal, and the deploy's own success output. In `--json` the success envelope carries `renamedFrom` (the old host) and `staleDisplayName` (the array of locations above), so an agent can fix them without parsing prose. The old name stays **reserved for you for 30 days** while links drain, then frees up for anyone. Within that window you can rename back at will; after it, the name is fair game. ## Listing your apps ```bash npx deepspace app list # every app you can access: id, URL, role, deploy state npx deepspace app list --json ``` `app list` shows every app you can **access** — the ones you own and the ones shared with you as a [collaborator](/guides/collaborators) — with a `ROLE` column telling them apart. Use it to recover an id you have lost track of — including when a quota refusal names an app you no longer recognize. ## Undeploy and revival ```bash npx deepspace app undeploy # resolves DEEPSPACE_APP_ID from the nearest wrangler.toml npx deepspace app undeploy --env staging # that environment's app npx deepspace app undeploy <app-id-or-name> # positional, registry-resolved — works from anywhere npx deepspace app undeploy --yes # skip the confirmation ``` The positional form takes an app id **or** a live subdomain name, so you can undeploy an app without being in its checkout. A positional target overrides `--env`; omit both to fall back to the surrounding `wrangler.toml`. **Undeploy confirms at an interactive terminal, defaulting to No.** At a TTY the command asks before the URL goes dark, and the question is the table below in one sentence: it names the app (`Take my-app (app_…) offline now?`), says the URL stops serving immediately and the data — records, messages, canvas state, cron history — is destroyed with the worker, and that secrets and the registration stay with the name reserved for 30 days. Declining refuses with the code `undeploy_declined` and changes nothing. `--yes` skips the prompt. Scripts and agents never see it: a non-TTY stdin or `--json` runs straight through, because the invocation itself is the consent — so an automated undeploy cannot hang waiting for an answer nobody will type. Read the sentence back to whoever asked before answering Yes: it is the only place the loss is stated at the moment of the decision. Undeploying twice is a no-op the second time, and reported as one: `alreadyUndeployed: true` with an empty `releasedHosts` (human: "was already offline — no URL was serving, so nothing changed"). A real takedown lists the released hosts. Undeploy deletes the Worker script and releases its routes, then best-effort deletes the recorded auto-provisioned Cloudflare resources. There is no separate data-purge step: **the app's Durable Objects — records, messages, canvas state, cron history — are destroyed with the Worker script as a consequence of its deletion**, and data in auto-provisioned resources can be lost too. What goes and what stays: | Removed | Retained | | ---------------------------------------------------------------- | -------------------------------- | | The deployed Worker | The app id and registry identity | | Its routes and subdomain serving | Collaborators | | Durable Object data — records, messages, canvas, cron history | The secrets store | | Auto-provisioned resources (best effort; their data can be lost) | The cloud Git repository | Because identity survives, a later `deploy` from the same repo **revives** the app. Active apps count against your tier's cap; undeployed ones do not — and revival is quota-checked, so reviving can be refused with `app_quota_exceeded` until you undeploy something else. Only the owner (or a platform admin) can undeploy. Collaborators cannot — destructive access is deliberately narrower than deploy access. ## Ownership transfer Transfer is a two-step handshake, GitHub-style: nothing changes until the recipient accepts. ```bash # Owner: npx deepspace app transfer offer teammate@acme.com # 7-day offer; --replace swaps a pending one npx deepspace app transfer status npx deepspace app transfer cancel # Recipient: npx deepspace app transfer accept --app app_01HZ… # the commit point — they own it now ``` On acceptance the app moves **as-is** — data, secrets, routes, custom domains — and only the owner (and billing) changes. The recipient must have signed in to DeepSpace at least once; offering to an unknown email fails with `user_not_found`. **There is no in-product notification.** Tell the recipient the app id out of band — they need it for `transfer accept --app`. An offer expires after **7 days**. Re-offering to a different email refuses with `confirmation_required` unless you pass `--replace`, which swaps the pending offer wholesale. ### Who can do what | Action | Who | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `transfer offer` | Owner (or platform admin on the owner's behalf). Collaborators can never start one — they could otherwise transfer the app to themselves. | | `transfer status` | Owner and the named recipient only | | `transfer cancel` | Either party | | `transfer accept` | The named recipient only | | `undeploy` | Owner or platform admin | A collaborator who is the *named recipient* participates as that recipient, not through the collaborator role. Platform admins operate deploy, secrets, and undeploy through their override, but the transfer handshake stays owner-scoped — the one exception is an operator opening an offer on behalf of an unreachable owner, and even then the offer is recorded as the owner's. ## See also * [Collaborators](/guides/collaborators) — who can ship the app without owning it * [Source control](/guides/source-control) — the app's one authoritative Git repository * [Custom domains](/guides/custom-domains) — domains bind to the id, not the name * [Command reference: `app`](/cli-reference/commands#app) Source: /guides/app-identity.md --- # Updating an app Moving an existing app onto a newer SDK with the read-only `app update` guide. An app's worker code is a copy, scaffolded once and owned by you. [`app update`](/cli-reference/commands#app-update) inspects that copy and returns a checklist for the running CLI's SDK release. It never edits the app: you choose and apply every dependency, source, and policy change. ## The sequence Ask the target CLI for guidance ```bash npx deepspace@latest app update --json ``` Run the **target** CLI, not only the version the app has installed. Its own package version is the target, and it owns that release's migration guidance. Read the whole checklist Inspect `status`, `dependencies`, `migrations`, `manualInstructions`, and the ordered `steps`. The command always reports `writes: []`: it does not rewrite `package.json`, stamp migrations, install packages, or scan unrelated repository files. Apply and validate the app-owned changes Make each dependency edit and migration change that applies to this app. Install only after reviewing the dependency plan, then type-check and test: ```bash npm install npm run type-check npx deepspace test run ``` After a migration passes, add its id to `deepspace.migrations.json`. If the named seam does not exist in this app, record the id as not applicable so a later run does not repeat it. Review, commit, and redeploy ```bash git diff git commit npx deepspace deploy ``` A deploy is what makes the updated dependency and app-owned code reach the live app. A deploy guard may still refuse an incomplete retrofit; fix the stated cause rather than marking its migration complete early. ## Reading the result | Field | Meaning | What you do | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | `status` / `ready` | `aligned` is the only ready state. `guidance_available` means work is listed; `dependency_unverified` means a local/VCS SDK is developer-owned; `cli_version_behind` means this CLI is older than the app; `version_gap_too_wide` means the releases must be reviewed one at a time. | Branch on the status, then read `steps`. These are successful guidance states, not partial writes. | | `dependencies: [{ dependency, from, to }]` | Manual edits needed in `package.json`. A published SDK also names the compatible direct `ai` dependency when the app declares one. | Make the edits together, review the manifest, then run the detected package manager's install command. | | `migrations: [{ id, description, files, guidance }]` | Outstanding release changes according to the app-owned ledger. `files` are the seams to inspect, not files the CLI searched or changed. | Apply the guidance where relevant, validate it, then add the id to `deepspace.migrations.json`. | | `manualInstructions` | A choice or verification the CLI cannot safely make, such as an app-owned users-schema visibility policy or an unverifiable local SDK. | Decide explicitly; never treat an empty write set as permission to skip it. | | `steps` | The ordered human checklist assembled from the fields above. | Follow it in order and commit only after type-checking and testing. | | `writes` | Always `[]`. | Use your normal editor and review workflow; there is no apply mode or install action. | A malformed `package.json` or `deepspace.migrations.json` refuses with `invalid_package_manifest` or `invalid_migration_manifest`. The command will not repair either source of truth. A version gap that is too wide returns `ok: true`, `ready: false`, and `status: "version_gap_too_wide"`; move through the intervening releases' [changelog](https://deep.space/changelog) rather than asking one release checklist to cover the whole history. ## Scaffolds that predate server-minted ids Older SDKs minted `DEEPSPACE_APP_ID` locally, at scaffold time, and no server ever registered it. Ids are server-minted at registration now, and an existing unregistered id **cannot be claimed**. Such an app shows up in one of two ways on the newest CLI: * `deploy`, `push`, or `secrets` refuse **`app_not_registered`** - the id no server has registered. * `app init` refuses **`app_not_registered`** too, instead of the old "already initialized", and ships the fix as its `action`. The fix is the same in both cases: ```bash npx deepspace@latest app init --new-id ``` This registers the directory as a fresh app under your account: a new id in `wrangler.toml`, new data, new secrets store, and new registration. There is no old platform state under the unregistered id. Commit `wrangler.toml` (the command offers that commit as its next action when needed), then run `app update` again. Two related refusals from the same era are `app_id_env_mismatch` (the browser bundle still carries an id frozen at scaffold time in `src/constants.ts`) and `app_id_define_unsubstituted` (constants moved to the build-time define without the `vite.config.ts` half). The deploy refusal prints the three-file retrofit, and `app update` reports the matching `2026-08-build-injected-app-id` guidance. Apply all three parts, validate them together, then record the migration id. `app update` never registers, deploys, or mutates the checkout. Registration is `app init`; shipping is `deploy`. If the app also needs a secrets config it never had, `deploy` refuses `secrets_config_missing` with the `secrets configs create` action - see [missing is not empty](/guides/secrets#missing-is-not-empty). ## Next steps * [`app update` reference](/cli-reference/commands#app-update) - the current fields and statuses. * [Refusal codes by command](/cli-reference/overview#refusal-codes-by-command) - malformed-manifest refusals and the rest of the CLI contract. * [App identity](/guides/app-identity) - what an id is, and why `--new-id` is a fork. * [Changelog](https://deep.space/changelog) - what changed in each SDK release. Source: /guides/updating.md --- # Source control Source is inferred from use, never declared: how DeepSpace and GitHub source work, what each release records, and the one permanent claim. Every app has **exactly one authoritative Git repository** — but as of v0.26.0 you never declare which. There is no setter, no registration step, and no transfer: **source is inferred from how you deploy, and each release records the evidence**. The one durable fact is a single, permanent claim: the first `deepspace push` (or an unclaimed app's deploy sync) claims DeepSpace source, once, forever. To see where an app stands: ```bash npx deepspace app source --json # read-only: reports, never changes npx deepspace status --json # sourceInference: what the NEXT deploy will do ``` ## The two experiences | Source | Experience | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **DeepSpace** | The packaged default. Commit-first: deploy publishes the attached clean branch automatically, and the `space` remote, `clone`/`push`/`pull`, [workspaces](/guides/workspaces), activity, releases, and rollback all work together. | | **GitHub** | Manual ownership, by inference. A checkout whose git remote points at GitHub deploys as GitHub — no gates, no credential probe, no claim step. Deploy ships the **local working tree** and performs no Git read, write, or verification. | Under GitHub inference, dirty and unpushed bytes are **valid deploy inputs**. The release records the repository the checkout actually pointed at plus a dirty flag (`releases` shows `GitHub · owner/repo, dirty worktree`), but carries no DeepSpace commit id; rollback uses the retained deployment bundle. Commit discipline is yours — the platform respects that. ## How inference decides An **unclaimed** app (no claim has ever happened) is decided per deploy, locally: * The checkout has a GitHub remote → the deploy ships as GitHub and records the observed repository as evidence on that release. * No GitHub remote → the deploy syncs to the DeepSpace cloud repo — **and that sync claims DeepSpace source, permanently** (announced on stderr at the moment it happens). Paths that skip the sync skip the claim too: `--no-push`, a branch tracking secret files, and workspace deploys of already-published commits. A **claimed** app ignores remotes entirely — whichever provider it claimed, that is the answer forever. **The claim is the one one-way door.** It fires at the git pack POST — the request that actually carries commits — never at the ref advertisement, so a `git push --dry-run` or a push your client abandons never pins the app. Owner and collaborator pushes both claim; an admin-tier push to an unclaimed app refuses (`admin_cannot_claim`). There is no unclaim and no transfer: to use a different source for the same code, register a new app (`deepspace app init --new-id`). ## `app source` is read-only ```bash npx deepspace app source # report: claimed source, or unclaimed npx deepspace app source --json # { source: {...} | null, revision, ... } ``` The old setter forms (`app source github`, `app source deepspace`) refuse with `source_inferred`, and no server route writes source (the retired `POST /source` answers a 410 `source_inferred` tombstone) — immutability is structural, not a policy an API could override. The revision counter still appears in `--json` surfaces (`revision` on `app source`, `sourceRevision` on release rows); on new apps it changes at most once, at the claim. ## Working under GitHub inference * **Push with ordinary Git.** Branches, tags, PRs — all normal. * **Deploy ships the working tree**, dirty bytes included; the release ledger labels dirty releases so a rollback never picks between clean and dirty blind. * **Do not run `deepspace push`** unless you mean to adopt DeepSpace source permanently — using it is choosing it. Once the app has a GitHub-source **release on the ledger**, `pull` and `clone` refuse with `source_managed_by_github`, naming the repository, rather than steering you into the claim — releases are the evidence, not the remote. A **never-released** app has nothing to infer from: `pull` and `clone` answer `no_cloud_repo` and still say "push first" — which *is* the permanent claim, so don't take that advice on a GitHub-owned app. * **Do not create or replace `origin` unprompted.** Wiring a repo to GitHub is the developer's call — and on an *unclaimed* app, adding or removing a GitHub remote changes what the next deploy does. When asked, use ordinary Git: ```bash git remote add origin git@github.com:<org>/<repo>.git git push -u origin main ``` **Never `git push --mirror`.** It acts on *every* ref — including refs that do not belong on GitHub. Push named branches and tags instead. ### The refusal the source verbs share `push`, `pull`, `clone`, and the workspace verbs all refuse GitHub-owned apps — claimed, or inferred from the release ledger — with one code, `source_managed_by_github`: > This app uses GitHub source (`owner/repo`). Use normal Git/GitHub for source operations… `--json` carries `repository` and `appId` alongside the code (plus `inferred: true` when the answer came from release evidence rather than a claim; the inferred variant's prose differs — match on the code, never the sentence). The refusal ships **no** executable action: which Git command to run is the developer's call. ### A review branch is not a second trunk A **claimed** DeepSpace-source app may still keep a review branch on GitHub — that is fine (the claim decided the source; remotes no longer matter). On an *unclaimed* app a GitHub remote is not inert: the next deploy ships as GitHub. But GitHub is then not the deploy source, and you must not describe both remotes as authoritative or maintain two trunks as a product invariant. One repository is the truth; the other holds a copy for a purpose. (A claimed DeepSpace app's remotes never change its source — the claim decided it.) ### CI deploys For CI, inspect the current flags with `npx deepspace auth login --help` and `npx deepspace deploy --help` rather than copying stale snippets. Store credentials in the CI secret manager — never in argv, repository files, or workflow logs. Remember that a CI checkout's remotes drive inference on an unclaimed app: a **full** clone with remotes stripped will sync to DeepSpace — and claim it. (A depth-1 clone refuses first, `shallow_repo`: unshallow it, or keep the GitHub remote.) ## Legacy declared-source apps Apps that declared a source under v0.25 and earlier keep their recorded provider; a recorded GitHub app behaves exactly like an inferred one (same refusals, same manual deploys), and `deploy` removes its stale `space` remote for it. The transfer machinery those versions documented is gone. ## See also * [Workspaces](/guides/workspaces) — the DeepSpace-source collaboration surface * [App identity](/guides/app-identity) — what stays put regardless of source; `app init --new-id` to fork * [Deployment](/concepts/deployment) — what a release records under each source * [Command reference: `app source`](/cli-reference/commands#app-source) Source: /guides/source-control.md --- # Workspaces The cloud repo, guarded push and pull, durable workspaces for parallel work, and the status and activity feed. DeepSpace-source apps have a real platform Git repository. This page covers how your checkout talks to it, how workspaces let several people or agents work on one app without colliding, and how to read the app's coordination feed. It all applies to [DeepSpace source](/guides/source-control) only — GitHub-source apps intentionally refuse these writes and workspaces; use ordinary GitHub branches and worktrees there. ## The `space` remote The platform remote is named `space` (under the production platform; `space-staging` when the staging environment is selected — see the Note below). The CLI's wrappers install or repair it and configure a Git credential helper for the platform host — global when the CLI is a durable executable, worktree-private when it is transient. After that, plain `git fetch` and `git push` work from the configured checkout. For a **new** checkout, use `deepspace clone`; an arbitrary `git clone` only authenticates automatically when the durable global helper is present. ```bash npx deepspace clone <app-or-id> [dir] npx deepspace push [-b <branch>] npx deepspace pull [-b <branch>] ``` `clone` creates a normal checkout. `push` and `pull` are **guarded** Git operations with stable refusal codes — they do not replace Git's history model, they wrap it with platform policy (see [the refusal contract](#the-refusal-contract) below). Keep `DEEPSPACE_ENV` consistent for a session. It selects which platform (production or staging) owns the remote, workspaces, releases, and activity — the remote is even named for it, `space` versus `space-staging` — so switching it mid-session points the same checkout at a different owner. `DEEPSPACE_DEPLOY_URL` does **not** select the platform; it only overrides one service's base URL. ## Workspaces A workspace is a **durable server ref plus task metadata**, materialized locally as a `ws/<id>` branch and worktree. Use one for each independent line of work: ```bash npx deepspace workspace new -t "wire RBAC into billing" npx deepspace workspace attach ws_… [dir] npx deepspace workspace sync npx deepspace workspace status npx deepspace workspace list [--all] npx deepspace workspace land [--into <branch>] [--validate] npx deepspace workspace drop [ws_…] ``` `new` creates the ref and the local worktree; `attach` materializes an existing workspace into a checkout on another machine; `sync` publishes your commits to the server ref; `land` merges into trunk and retires the workspace; `drop` discards one. ### Operating rules These are the rules that keep parallel work safe. They are worth internalizing rather than rediscovering through refusals: * **Commit WIP, then `workspace sync`.** Sync is what makes work durable and visible to collaborators. An unsynced workspace exists only on your disk. * **Run `sync` and `land` from the workspace checkout.** `-w`/`--workspace` selects *identity* only — it does not make another checkout a safe place to operate from. * **`land` is an ordinary merge.** It preserves the workspace commits, then deletes the server workspace ref. Local cleanup removes only a checkout carrying DeepSpace's private ownership marker — worktrees you (or another tool) created by hand are retained. `--keep-worktree` opts out of cleanup entirely. * **Overlap reports are advisory.** They compare live peer tips so you can coordinate; they are not file-ownership locks. Read them, then talk. * **Managed workspace directories anchor under the primary checkout**, even when you invoke the command from another linked worktree. Each checkout installs its own dependencies — never symlink `node_modules` across worktrees. * **Never plain-push a `ws/*` branch to `space`.** Publish with `workspace sync` so ref, metadata, and activity stay coherent. * **Deploying from a workspace requires its exact HEAD to be synced first.** A refusal here means the server would ship a tip you have not published — follow the refusal's action rather than deploying an older tip. ### Dropping a workspace `workspace drop` refuses to delete **unpublished commits**. It verifies the tip is represented in cloud workspace, base, or landed history, then deletes the ref with compare-and-swap — so a branch that advanced concurrently survives the delete. When it refuses, follow its `workspace sync` action (publish, then drop) or pass `--keep-worktree`. Never force-clean a workspace by hand; the guard exists precisely because "I'm sure it's merged" is the famous last words of lost work. ## Guardrails on push and pull * **`pull` fetches and fast-forwards.** If dirtiness, divergence, or another worktree holding the branch prevents the local update, it stops safely — and may return one local action to run. * **`push --force` still refuses to discard a remote commit** your local branch does not contain. Force is for deliberate ref moves, not conflict recovery — integrate the remote tip instead. This guard lives in the CLI wrapper only: a plain `git push --force space` through the credential helper bypasses it, and the server does not re-check it. * **Push preflight rejects committed secret files.** A branch tracking `.dev.vars`, `.env*`, `.npmrc`, `.envrc`, or `.mcp.json` (templates like `.env.example` excepted) is refused with `secret_in_history` before any bytes leave your machine. Unlike the force guard, this rule **is also enforced server-side** — the platform refuses a pushed pack that introduces a secret file, so raw `git push` is no bypass either. Fix the branch — untrack the file, gitignore it, commit — rather than looking for a bypass flag; there deliberately isn't one. * **Size ceilings: 20 MiB per object, 32 MiB of compressed history per push**, refused with `push_too_large` — why untracking an oversized file doesn't fix it, and what does, is in the [command reference](/cli-reference/commands#push). ## The refusal contract Guarded operations refuse with **stable codes**, and when exactly one deterministic recovery exists they attach an executable action: | Code | Meaning | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `non_fast_forward` | The remote ref moved past your local branch | | `behind_trunk` | Deploy only: your branch is strictly behind its cloud-repo branch - deploying would take already-landed work off the live app | | `diverged` | Local and remote histories have split | | `dirty_worktree` | Uncommitted changes block the operation | | `workspace_unsynced` | The workspace tip is not published to the server ref | A refusal's `action` follows the CLI-wide [`action` contract](/cli-reference/overview#the-action-contract) — execute it as given; when there is none, the recovery requires judgment (inspect the Git state and decide), never an invented command. ## Status and activity Two read-only commands orient you — after a context switch, in a fresh shell, or when resuming automation. ### `status` ```bash npx deepspace status npx deepspace status --json ``` `status` reports **present facts**: session, app, install state, branch/workspace, sync relation, and the live release where reachable. It does not derive a workflow or print a synthetic next step. Interpret the facts, then run the relevant command. ### `activity` Activity is **stateless on the server; the caller owns the cursor**: ```bash npx deepspace activity # events after cursor 0 npx deepspace activity --since <cursor> # strictly after the cursor npx deepspace activity --follow # tail from now npx deepspace activity --follow --since <cursor> ``` Persist the last returned cursor whenever continuity matters. Omitting `--since` in one-shot mode replays from the beginning; omitting it in follow mode starts at the current tail. `activity --follow --json` emits **NDJSON** — the frame grammar (`ready`, `activity`, `transport`) is in the [command reference](/cli-reference/commands#activity). Events are **facts, not instructions** — pushes, workspace lifecycle, releases. A peer's `land` event tells you trunk moved; it does not tell you to rebase. Decide what to do from your own state. ## See also * [Source control](/guides/source-control) — DeepSpace versus GitHub authority, and moving between them * [Collaborators](/guides/collaborators) — who can push and deploy * [Command reference: `workspace`](/cli-reference/commands#workspace) and [version control](/cli-reference/commands#version-control) * [CLI overview](/cli-reference/overview) — exit codes and the JSON action contract Source: /guides/workspaces.md --- # Custom domains Optionally buy and attach your own domain to your DeepSpace app. By default, every deployed app lives at `<name>.app.space` - a fully production-grade URL with SSL that most apps ship and stay on. When you want a branded URL, you can attach your own domain - `myapp.com`, `myapp.ai`, `myapp.io` - directly from the CLI. Domain purchase, DNS, and SSL are handled end-to-end; you don't touch a registrar dashboard. ## How it works DeepSpace registers domains through a hybrid backend: * **Cloudflare Registrar** for \~27 TLDs at cost (`.com`, `.dev`, `.app`, `.xyz`, and others) * **Porkbun** for `.ai`, `.io`, `.me`, `.co`, ccTLDs, and other variants Routing uses Cloudflare Custom Hostnames against the platform's SaaS zone (`app.space`). After purchase, the platform provisions ownership and DCV records on the new domain's zone, then activates a hostname route. Billing is via Stripe Checkout - the same payment flow as in-app purchases. Auto-renew is on by default. ## Search and buy ```bash # Find an available domain and its price npx deepspace app domain search my-startup npx deepspace app domain search my-startup --limit 20 # Buy via Stripe Checkout (browser opens for payment) npx deepspace app domain buy my-startup.com ``` `buy` opens a Stripe Checkout tab in your browser. After payment, the CLI polls for provisioning: * **Cloudflare Registrar TLDs** (`.com`, `.dev`, `.app`): \~60-90 seconds * **Porkbun TLDs** (`.ai`, `.io`, `.me`): 15-60 minutes (registry-side NS propagation) You can Ctrl-C out of the polling loop without losing progress - provisioning continues server-side. Re-check with `domain status <domain>`. ### Buy options ```bash # Print the Checkout URL instead of opening a browser npx deepspace app domain buy myapp.com --no-open # Exit immediately after Checkout session is created (no polling) npx deepspace app domain buy myapp.com --no-wait # Skip the confirmation prompt (required in non-TTY contexts) npx deepspace app domain buy myapp.com --yes # Combine with --app to buy and attach in one command npx deepspace app domain buy myapp.com --app my-app ``` For `buy` and `attach`, `--app` accepts the **immutable app id or the current live name**; without it, the CLI resolves `DEEPSPACE_APP_ID` from the surrounding app directory. Names are URL leases, not durable identity, so scripts should prefer the id — see [app identity](/guides/app-identity). With `--json`, `buy` returns the Checkout session immediately **without polling**, and supplies the `domain status` follow-up action to run once payment completes. **Do not automate `buy` in CI or tests.** It spends real money and enables auto-renew. Purchase and renewal changes are user decisions - obtain explicit approval before passing `--yes`, and never use a real owned domain for routine tests. ## List, inspect, and manage ```bash # Domains you own npx deepspace app domain list npx deepspace app domain list --json # Detail for one domain (registrar status, hostname status, expiry, errors) npx deepspace app domain status myapp.com npx deepspace app domain status myapp.com --json ``` Domains, billing, and renewal status are also visible in the web dashboard at [dashboard.deep.space](https://dashboard.deep.space). ## Re-point a domain at a different app ```bash npx deepspace app domain attach myapp.com --app new-app-name ``` `attach` re-points an owned registration to the **resolved immutable app id** - the CLI resolves whatever you pass to `--app` (id or live name) to the id and binds that. Use it when: * You want to move a domain from a staging app to production * You're consolidating multiple domains under one app Because the binding is to the id, **renaming an app does not require re-attaching its domain**. The domain follows the app through any rename, along with data, secrets, and collaborators - see [app identity](/guides/app-identity#renaming-an-app). ## Detach without releasing ```bash npx deepspace app domain detach myapp.com --yes ``` `detach` removes routing but keeps the registration on file. You keep owning the domain and can re-attach later. There is no `domain release` - releasing a registration goes through the registrar's own portal. ## Auto-renew ```bash # Disable auto-renew (will expire at end of term) npx deepspace app domain renew myapp.com --auto off # Re-enable npx deepspace app domain renew myapp.com --auto on ``` ## Agent-friendly flags The CLI is designed for both humans and automation: | Flag | Available on | Purpose | | ------------- | ------------------------------------------- | ---------------------------------------------- | | `--json` | `search`, `buy`, `list`, `status`, `attach` | Machine-readable stdout | | `--yes` | `buy`, `detach` | Skip confirmation prompt (required in non-TTY) | | `--no-wait` | `buy` | Exit after Checkout session creation | | `--no-open` | `buy` | Print URL instead of opening browser | | `--limit <n>` | `search` | Cap the number of results | ## Pitfalls \`--app\` defaults to your current directory If you run `deepspace app domain buy myapp.com` outside an app directory without `--app`, the CLI errors with `No app specified. Pass --app <name>, or run from an app directory with a wrangler.toml.` Premium domains can have different registration and renewal prices `chargedCents` in `list` / `status` is what you paid this year. The pricing object exposes separate `registrationCost` / `renewalCost`. For non-premium TLDs they match; for premium domains, introductory pricing can reset at renewal - read `domain status` before assuming. Don't wrap \`buy\` in \`timeout N\` The CLI polls for up to 5 minutes (Cloudflare TLDs) or 60 minutes (Porkbun TLDs). An artificial timeout aborts before payment completes. Use `--no-wait` for fire-and-forget; otherwise let it run and Ctrl-C if needed (safe; provisioning continues server-side). ## Pricing visibility The CLI shows the price during `search` and the Checkout flow. The price starts from the registrar's cost (Cloudflare Registrar or Porkbun) and DeepSpace adds a markup: you pay the registrar cost × 1.3, plus a $1 fee. Renewals charge to your saved Stripe card automatically. ## Next steps * [App identity](/guides/app-identity) - the immutable id that domains bind to. * [Deployment](/concepts/deployment) - how deploys work, and where subdomains come from. * [CLI reference](/cli-reference/commands#app-domain) - all `domain` subcommands and flags. Source: /guides/custom-domains.md --- # Collaborators Let other people deploy and operate your app. Collaborators are DeepSpace users the app **owner** authorizes to work on the app. Ownership never moves: the deployed worker keeps the owner's identity and billing. Authorization keys to the app's immutable `DEEPSPACE_APP_ID`, so there is no per-resource grant and no link step. **Not the same thing as record collaborators.** The `collaboratorsField` in a [collection schema](/sdk-reference/worker/schemas) controls who can see *rows inside* your app. This page is about who can *ship* the app. ## Managing collaborators All four subcommands are **owner-only**. Run them from the app checkout, or pass `--app <id or name>`. ```bash npx deepspace app collaborators list npx deepspace app collaborators add teammate@example.com npx deepspace app collaborators cancel teammate@example.com npx deepspace app collaborators remove teammate@example.com ``` `list` prints two sections - current collaborators, and any live invites with their expiry. With `--json` you get `{ collaborators, pending }`. **Collaborators get owner-equivalent deploy and secrets access.** They can read and overwrite every secret in the app's store and ship any code they like to your production URL, billed to you. Only add people you trust. ## Adding someone `add` has two paths, depending on whether the email already belongs to a DeepSpace user. The email is an existing DeepSpace user They become a collaborator immediately. The command prints `✓ <email> can now deploy <app>`. The email has no DeepSpace account yet The server creates a **pending invite** and emails the person a `/join/<token>` link. The invite is billed to you as a transactional email, and expires after 7 days. Signing in alone does **not** grant access — the invitee must explicitly accept, one of two ways: * open the emailed link, sign in with the invited address, and accept there, or * sign in and accept from the CLI — the path a headless agent takes: ```bash npx deepspace app collaborators invites # lists invites waiting for your email npx deepspace app collaborators accept <app-id> # accepts one ``` Both are bound to the same rule: the signed-in email must match the invited email. Re-running `add` while an invite is still live returns `already_invited`: no second email is sent and you are not charged again. To reset an invite, `cancel` it and then `add` again. **`add` grants plaintext access to every app secret.** A collaborator can `secrets list`, `get`, `download`, and `pull` every value in every config of the app's store, and can overwrite or delete them - not just deploy. The command says so on the way in (`collaborators add --help`) and again on the way out: the success line spells out the grant, and `--json` carries it as `"grants": ["deploy", "secrets:read", "secrets:write"]` so an agent can record what was given away without parsing prose. `grants` rides **every** outcome of `add`, invites included - accepting an invite confers exactly the same access. Treat `add` as handing over the app's credentials, because it is. ### Failures you may see | Code | Meaning | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `test_account_cannot_be_collaborator` | `@deepspace.test` accounts can never be collaborators. Use a real account. | | `insufficient_credits` | Inviting a brand-new email sends a billed transactional email. Top up and retry. | | `invite_email_failed` | Transient - the invite email could not be sent. **You were not charged**; the pending row is rolled back. Retry in a moment. | | `no_pending_invite` | `cancel` was given an email with no live invite. | | `not_a_collaborator` | `remove` was given an email that isn't on the app. | Inviting a stranger by email works. That is **not** true of [`app transfer offer`](/cli-reference/commands#app-transfer), which refuses with `user_not_found` unless the recipient has signed in to DeepSpace at least once. ## What a collaborator can and can't do | Action | Allowed | | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `deploy` (including `--env`) | **Yes** - on-behalf. Billing stays with the owner. | | `dev start` / `test run` | **Yes** | | `secrets list` / `get` / `download` / `pull` | **Yes** - every config in the app's store | | `secrets set` / `upload` / `delete`, `configs create` / `delete` | **Yes** - writes are audited under the collaborator's own id | | `app undeploy` | No - owner (or platform admin) only | | `app transfer offer` | No - owner only | | `app transfer status` / `cancel` / `accept` | Only as the **named recipient** of a pending offer. Either party may cancel; only the recipient may accept. | | `app collaborators add` / `remove` / `cancel` | No - owner only | | `app source` (report source) | **Yes** - read-only for everyone; source is inferred from use and cannot be changed by any verb (see [source control](/guides/source-control)) | A collaborator who is the *named recipient* of an ownership transfer may accept it - but as that recipient, not through the collaborator role. `transfer status` is likewise visible only to the owner and the named recipient; other collaborators cannot see a pending offer at all. ### Platform admins are not super-collaborators Through the platform override, admins can **deploy, manage secrets, and undeploy**. They cannot manage collaborators (and [source](/guides/source-control) is inferred from use — no one changes it, admin pushes to an unclaimed app refuse `admin_cannot_claim`), and ownership transfer remains the owner's handshake - an operator can at most open an offer on behalf of an unreachable owner, and even that is recorded as the owner's offer. The override never turns a collaborator into an admin. ### On-behalf deploys When a collaborator deploys, the CLI prints `Deployed on behalf of owner <id>`. The release ships their code plus the store's secrets. Nothing about ownership changes, and **billing** follows the owner. **The release is attributed to the collaborator, not the owner.** The ledger records the *caller* as the release actor, which is why `releases --json` names the collaborator on the releases they shipped and `status --json` reports `byYou: true` for them. Only the ownership and billing relationship is the owner's. Earlier CLI versions printed a warning claiming the release was attributed to the owner; that warning contradicted the ledger it was describing and has been removed. ## Getting started as a collaborator You don't need to link or claim anything. The app's `wrangler.toml` already carries its `DEEPSPACE_APP_ID`, which is the whole authorization key. Get the code ```bash npx deepspace clone <app-name> cd <app-name> npm install ``` Or clone the GitHub repo if the app uses [GitHub source](/guides/source-control). A DeepSpace-source app has **no leased name before its first deploy**, so clone it by id in that case: `npx deepspace clone <app-id>` (once you are added, your own `deepspace app list` shows the id; it is also in the repo's `wrangler.toml`). After the first deploy, the name resolves too. Sign in ```bash npx deepspace auth login ``` Work normally ```bash npx deepspace dev start npx deepspace test run npx deepspace deploy ``` No linking step. Access is resolved from the app id on every request. **Discovering shared apps.** `deepspace app list` shows every app you can access - your own and the ones shared with you - with a `ROLE` column telling them apart. Once the owner adds you, look up the shared app's name or id there. The one thing `app list` cannot resolve is a name that does not exist yet: before its first deploy an app has no leased name, so clone by id. If you actually wanted your own separate copy rather than to collaborate, run `npx deepspace app init --new-id` to fork the checkout into a distinct app with fresh data and a fresh secrets store. ## Revoking access ```bash npx deepspace app collaborators remove teammate@example.com ``` Removal takes effect immediately for authorization: the next `deploy`, `secrets`, or `dev` call from that user is refused with a 403 `Not the app owner or a collaborator`. **Revocation is not retroactive.** It stops future access; it does not undo what the collaborator already did. A collaborator could have read every secret in the store while authorized, so **rotate any secrets they had access to** after removing someone. It also does not roll back releases they shipped - use [`rollback`](/cli-reference/commands#rollback) for that. To rescind an invite that was never accepted, use `cancel` rather than `remove` - the person is not a collaborator yet, so `remove` refuses with `not_a_collaborator`. ## Troubleshooting **403 `Not the app owner or a collaborator` on deploy or secrets.** Either you were never added, your access was revoked, or you are signed in as a different account than the one that was invited. Check with `npx deepspace auth whoami`. **The invitee never got the email.** Confirm the invite is live with `collaborators list`. If a pending row exists but no email arrived, `cancel <email>` and then `add` again - a re-`add` against a live row short-circuits without sending. ## See also * [Command reference: `app collaborators`](/cli-reference/commands#app-collaborators) * [App identity](/guides/app-identity) - ids, renames, undeploy, and ownership transfer * [Deployment](/concepts/deployment) - what a release actually contains * [Permissions](/concepts/permissions) - in-app roles, which are a separate system Source: /guides/collaborators.md --- # Design overview The design philosophy, the five-step landing workflow, and the fourteen hard rules that keep a page from looking AI-generated. DeepSpace scaffolds working apps fast — and the scaffold's UI is deliberately a placeholder, not a house style. This section is the design system for replacing it: an opinionated workflow that produces pages a person would recognize as designed for *this* product, plus a mechanical gate that catches the most common tells of template output. ## Two surfaces, two guides Every app has two front-of-house surfaces, and they need different treatment: | Surface | What it is | Guide | | ---------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | **Landing page** | The marketing front door — a static page for visitors and crawlers | This page + [Design direction](/design/direction), [Style tile](/design/style-tile), [Patterns](/design/patterns) | | **Product home** | The authenticated app surface — the board, list, feed, or document the user actually works in | [Product polish](/design/product-polish) | Use the landing workflow below for a marketing, splash, or landing page (or when giving design feedback on one). Use [Product polish](/design/product-polish) for the dynamic home, theme, primitives, and interaction feedback. The two share a palette and body font but are designed by different procedures. ## The landing workflow — five steps, in order The workflow is **Direction → Style Tile → one inspiration archetype → composition → grep gate**. Skipping ahead — reaching for patterns before committing to a direction — is how generic pages happen. Decide where the landing page lives Two paths: * **Static (default):** rewrite `src/pages/index.tsx` at `/`. Top-level pages mount no providers or app chrome, which suits marketing and crawlers. * **Dynamic feature:** `npx deepspace add landing` installs `(app)/landing.tsx`, primitives, and optional sections. Providers and global navigation mount. Treat every installed section as a skeleton; choose one real front door and remove or repoint the other. Either path, the rest of the workflow is the same. If the landing lives under `(app)/`, apply the nav-hiding patch below first. Fill the Design Direction block before any JSX At the top of the landing page file, write a prose block with two halves: a 6-prompt **brief** (product, emotion, visual metaphor, three references, signature element, hero visual) and a 6-token **Style Tile** (color, type pair, theme, art direction, motion personality, voice). The brief is prose; the Style Tile is six one-line commitments. If you can't fill a prompt, you don't understand the product well enough to design for it yet — go read the rest of the app first. See [Design direction](/design/direction) for how to write a good brief and [Style tile](/design/style-tile) for the commitment menus. Apply the sentence test (below) at the end. Read one inspiration archetype Use [Worked examples](/design/worked-examples) to pick the closest *emotion*, then read only that one example. Learn how its Direction becomes code; never clone it. Compose the page Build section by section from the [pattern library](/design/patterns), within the composition budget below. Adapt each pattern's content and visual tokens to serve your Direction. **The pattern is the structure; your Direction is the soul.** The installed landing sections are an alternate skeleton, but they contain known semantic-token violations. The gate will find them; fix rather than suppress every hit. Fill images, run the grep gate * Generate atmospheric images through a cataloged image integration; inspect its schema and include `no text, no words, no letters, no writing, no logos` in every prompt. * Persist generated images via `useR2Files` if you need a stable URL (otherwise the image regenerates on every render). * Build product mockups as animated React components (inline SVG, styled divs, Framer Motion). **Never** use AI-generated images for UI screenshots. * Fill every image slot before finishing. * Run the complete gate in [Anti-AI gate](/design/anti-ai-gate) from the app root. Any hit is a bug to fix. ## Hide the global navigation on the landing route Required if the landing lives under `(app)/`. Static landings inherit no app chrome, but a landing under `(app)/` renders below the app's global `<Navigation />` — hide it on that route so it does not stack with landing chrome (two stacked navs are the clearest telltale of a bolted-on landing): ```tsx src/pages/(app)/_layout.tsx import { useLocation } from 'react-router-dom' export default function AppLayout() { const { pathname } = useLocation() const isLanding = pathname === '/landing' return ( <div className="flex h-screen flex-col"> {!isLanding && <Navigation />} <main className="min-h-0 flex-1 overflow-y-auto"><Outlet /></main> </div> ) } ``` Keep the layout's existing providers and suspense boundary around this conditional. ## The Direction block lives in source Put the Direction in a multi-line comment at the top of the landing page file (`src/pages/index.tsx`, or `src/pages/(app)/landing.tsx` on the feature-install path). It stays in source as documentation: ```tsx src/pages/index.tsx /** * Design Direction * * Product: <one sentence — who it's for, what it does> * Emotion: <one specific feeling — not "trust" or "excitement"> * Metaphor: <a concrete real-world image> * References: <three from OUTSIDE this product's category> * Signature: <the ONE memorable visual this page has> * Hero: <what animates on screen in the first 5 seconds> * * Style Tile * - Color: <dominant + accent + saturation> * - Type: <heading font + body font + why> * - Theme: <light | dark + why> * - Art direction: <one archetype from the style tile> * - Motion: <one personality from the style tile> * - Voice: <three behaviors, semicolon-separated> */ ``` ## The composition budget A landing page is a fixed number of sections, not a buffet: | Section | How many | | --------------- | ----------------------------------------- | | Navigation | 1 (or 0 — some pages need none) | | Hero | 1 | | Features | 1 (sometimes 2) | | Social proof | 0–1 (only if you have real proof) | | CTA | 1 | | Footer | 1 | | Scroll & motion | 0–N (only if your Direction calls for it) | A typical page uses 4–5 section types. A calm or quiet direction skips scroll choreography entirely. ## The sentence test Ask of everything you wrote in the Direction block: **could this describe any other product?** If yes, rewrite. * **Fails:** "A modern SaaS tool. Clean and trustworthy. Like Stripe and Linear. A floating mockup." * **Passes:** "A post-run journal asks runners one question. It feels like a handwritten postcard on a scratched table; a new cursive question fades in on each visit." ## Hard rules (non-negotiable) These are grep-checkable. Shipped violations mean a broken landing page. | # | Rule | Fix | | -- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | **Hero headline: 3–8 words.** No exceptions. | Shorten; put explanation below. | | 2 | **Body copy: under \~150 words total** across the whole page. | If you're writing a paragraph, replace it with a visual. | | 3 | **No 3-identical-cards pattern** for features — three of the same thing with the same structure. | Redesign: tabs, alternating rows, bento grid, single showcase, or a typographic list. | | 4 | **No purple-to-indigo, violet, or blue-to-purple gradients.** The most-common AI color tell. | Use your accent color in semantic `primary` shades only, from the app theme. | | 5 | **No hardcoded colors in JSX.** No hex, no `violet-400`, no `indigo-500`, no `rgb()` or `rgba()`. | Semantic tokens only: `bg-background`, `text-foreground`, `bg-primary`, `bg-muted`, `border-border`, `text-muted-foreground`, and friends. | | 6 | **No fractional opacity on foreground.** Patterns like `bg-foreground/[0.06]` or `border-foreground/[0.08]` are old-template tells. | Use `bg-muted`, `text-muted-foreground`, `border-border`, or `bg-card`. | | 7 | **Pick a font that's clear, elegant, and fits the product — never gimmicky.** The test: would a thoughtful designer ship this font for *this* product? | Reason about the product's tone first, then pick a font that serves it from the [pairing tables and blacklist](/design/style-tile#2-type-pair). | | 8 | **Product mockups must be React components**, never AI-generated images. | Build the UI in React/inline SVG; generated images are atmospheric only. | | 9 | **Every AI-generated image prompt must include** `no text, no words, no letters, no writing, no logos`. | Add the clause to any prompt missing it. | | 10 | **Dramatic type scale.** Headlines at least 3x the size of body text. | If they look similar, the page looks flat — enlarge the headline. | | 11 | **One commanding visual in the hero.** A hero that is just text on a flat background is a failure. | Add an animated React mockup, a full-bleed atmospheric image, a bold environment, or your signature element — something. | | 12 | **Never ship the scaffolded `landing` feature sections verbatim.** Shipping `HeroSection` + `FeaturesGridSection` + `TestimonialsSection` + `FAQSection` with placeholder copy swapped in reproduces the exact AI-generic look this workflow is designed to break. | The scaffold is a skeleton: rewrite installed sections; [worked examples](/design/worked-examples) are read-only teaching artifacts. | | 13 | **Animations must respect `prefers-reduced-motion`.** | Wrap the landing tree in `<MotionConfig reducedMotion="user">` from framer-motion — that auto-disables transform/layout animations for users who request reduced motion. Manual gates (call `useReducedMotion()` and short-circuit) are needed only for `useTransform` from `useScroll` (parallax, pinned scroll), `setInterval`/`setTimeout`/`requestAnimationFrame` loops, and CSS keyframes. | | 14 | **No pictograph emoji** — no rocket ships, sparkles, light bulbs, party poppers, stars, flames, hearts, waving hands, or anything else in the U+1F000–U+1FFFF range. They render inconsistently across platforms and read as AI-generated. | Use `lucide-react` icons or inline SVG. Plain typographic marks (`✓ ✗ → ← ↑ ↓ ★`) **are** allowed as text glyphs — eyeball BMP emoji the gate misses. | See [Anti-AI gate](/design/anti-ai-gate) for the complete grep gate commands and the eyeball checklist. Run the gate before declaring the page done. ## Next steps * [Design direction](/design/direction) — write the six-prompt brief. * [Style tile](/design/style-tile) — commit to the six visual tokens. * [Patterns](/design/patterns) — compose the page from proven section structures. * [Worked examples](/design/worked-examples) — see five complete Direction-to-code translations. * [Product polish](/design/product-polish) — design the authenticated app surface. Source: /design/overview.md --- # Design direction How to write the six-prompt brief that makes every downstream design choice non-generic. The Design Direction is a six-prompt brief written as a comment at the top of the landing page file, before any JSX ([where it lives](/design/overview#the-direction-block-lives-in-source)). Use this page when first filling it, or when a draft fails the sentence test. Commit to the Direction before choosing patterns, type, or motion. ## The six prompts, explained ### 1. The product — one sentence, what it does for whom Name the user, activity, and frame — not a tagline or feature list. * **Bad:** "An AI-powered productivity platform." * **Good:** "A daily journal for runners that asks one question after every run." ### 2. The one emotion this landing page should evoke Name a felt moment, not a marketing category. * **Bad:** "Trust and professionalism." * **Good:** "Sunday morning, second coffee, nowhere to be." ### 3. The visual metaphor — a concrete real-world image Choose a photographable image, not software or adjectives. It should imply color, texture, light, type, and motion. * **Bad:** "Clean and premium." * **Good:** "A handwritten recipe card on butcher block in morning light." ### 4. Three references from outside this product's own industry Pick products, magazines, films, books, brands, or objects that share the emotion and metaphor but not the category. * **Bad for a dev tool:** "Stripe, Linear, Vercel." * **Good:** "Teenage Engineering TX-6, Swiss railway signage, modular-synth patch cables." ### 5. The one signature visual element this page will have Choose one memorable element a person could identify in a sentence: torn-paper SVG dividers, a 4.5-second breath circle, a realistic live terminal. ### 6. The hero visual — concretely, what animates on screen Describe what moves in the first five seconds, not "a mockup with motion." Example: "A three-column Kanban moves one card every two seconds." ## The Style Tile — concrete commitments Translate the brief into the six one-line Style Tile commitments — color, type pair, theme, art direction, motion personality, voice — picked from the menus in [Style tile](/design/style-tile). Keep them beside the Direction in the source preamble, and scan only the matching menus — don't browse all of them. The per-token cohesion constraints there govern what matches the app (palette, body font, theme) and what may differentiate the landing (heading type, art direction, motion, voice) without making sign-in feel like a different product. Voice must be **mechanically checkable behavior** — for example, "second-person; max 12 words; never starts with 'we'" — not adjectives. "Friendly" cannot be verified against a sentence; "never starts with 'we'" can. Finalize the [app theme](/design/product-polish#theme-create-one-for-the-app) first — the landing reads the same theme tokens the app does, and writing one against a placeholder theme means redoing it. Finish with the [sentence test](/design/overview#the-sentence-test): could this describe any other product? If yes, rewrite. ## If you're stuck * If the product is unclear, inspect its data model and main flow. * If the brief describes a category, identify what makes this app unusual. * Prefer a specific, revisable direction over a generic safe one. Source: /design/direction.md --- # Style tile The commitment menus for the six Style Tile tokens: color, type pair, theme, art direction, motion personality, and voice. The [Design Direction](/design/direction) asks for six concrete tokens — color, type pair, theme, art direction, motion, voice. This page gives you the menu for each. **Don't read linearly.** Jump to the commit you're filling, scan that one table, pick, move on. Each section ends in a pick rule so you're not browsing forever. ## 1. Color **The 60-30-10 rule** (universal): 60% dominant (usually a neutral), 30% secondary, 10% accent (CTAs only). 2–3 core colors total — anything more reads as chaotic. | Hue family | Reads as | Use for | | ------------- | ------------------- | -------------------------------------- | | Blue | Trust, stability | B2B, finance, healthcare, legal | | Red / orange | Urgency, appetite | Food, retail, urgency-driven CTAs | | Green | Growth, health | Wellness, finance/growth, eco | | Purple | Premium, creative | Beauty, luxury, creative tools | | Yellow | Optimism, attention | Kids, food, attention-grabbing | | Black + white | Editorial, premium | Fashion, design agencies, premium SaaS | | Earth tones | Warm, hand-crafted | Cooking, outdoor, slow brands | **Saturation matters as much as hue.** Vivid reads as impulse and younger audiences. Muted or dusty reads as considered, premium, older audiences. The same hue at different saturation is a different brand. **Hard floor:** WCAG 4.5:1 contrast for normal text, 3:1 for large. **Cohesion constraint:** the palette **must** match the app's `@theme` block in `src/styles.css`. The landing page reads the same `--color-background` / `--color-primary` / `--color-foreground` the app does. If you want a different accent for the hero, scope it to the hero component — don't edit the global tokens. **Pick rule:** name a dominant + accent + saturation level in one sentence. Example: *"Warm cream dominant, deep terracotta accent, muted everywhere except CTAs which go vivid."* ## 2. Type pair **Two fonts max.** Heading carries personality, body carries readability. A third font is only OK if it has a distinct role (e.g. mono for code). ### Reason before you pick Don't jump straight to a font row. First answer in one sentence: *what does this product feel like, and what does its user value most about reading it?* A meditation app values calm and clarity — pick a font that gets out of the way. A cookbook values warmth and craft — pick a font with serif character. A dev tool values precision — pick a geometric or monospace font. The font should serve the product's tone, not decorate it. **Clarity and elegance always beat distinctiveness for distinctiveness' sake.** ### The clarity + elegance test Every headline font you pick must pass both: 1. **Clarity** — at a glance, can a stranger read it without effort? If a letterform feels like a puzzle, it fails. 2. **Elegance** — does it feel considered, restrained, and current? If it feels like a costume (period-piece, novelty, or "look at me" display type), it fails. **Avoid these — they read as gimmicky, not designed:** Syne, Bebas Neue, Anton, Fjalla One, Oswald, Impact, Josefin Sans, Pacifico, Lobster, Comic Sans, and anything labeled "display" with extreme weights, condensed widths, or decorative serifs. Pick one of these only if you can defend the choice in one sentence relative to *this* specific product. ### Headline + body pairings by vibe | Vibe | Heading options | Body options | Suits | | ------------------ | ------------------------------------------------------------------------------ | -------------------------------- | ---------------------------------------------------- | | Modern / clean | Inter, Montserrat, DM Sans, Manrope, Space Grotesk | Inter, DM Sans, Source Sans 3 | Productivity, B2B, dashboards, most SaaS | | Editorial / warm | Fraunces, Playfair Display, Source Serif Pro, Spectral, Lora, DM Serif Display | Source Sans 3, Lato, Inter | Magazines, longform, cooking, wellness | | Premium / refined | Cormorant, EB Garamond, Fraunces, Playfair Display | Lato, Inter, Karla | Fashion, beauty, agencies, design-conscious products | | Technical / dev | JetBrains Mono, IBM Plex Mono, Space Mono, IBM Plex Sans | Inter, IBM Plex Sans, DM Sans | Dev tools, APIs, dashboards | | Friendly / playful | Nunito, Quicksand, Fredoka, Baloo 2 | Nunito, Open Sans, Source Sans 3 | Kids, indie consumer, education (use restraint) | **Hard don'ts:** * Don't pair two serifs (they fight). * Don't use Arial, Helvetica, Times, or `system-ui` as the *headline* font — that's the most-common default-mode tell. * Inter, Montserrat, and DM Sans are valid headline picks when the product calls for clarity over personality. They are **not** a fallback — they are a deliberate choice you can defend in one sentence. **Cohesion constraint:** the **body** font should match the app's body font. The **heading** font is your free pick — the landing page can have a more distinctive heading voice than the app itself. Load the heading font via `<link>` in `index.html` or a CSS `@import` in `styles.css`, and reference it via a Tailwind utility class (`font-serif`, `font-display`) or a scoped font-family declaration. **Pick rule:** name one heading font + one body font, plus one sentence on why this product needs them. Example: *"Fraunces (heading) + Source Sans 3 (body) — the app is a cookbook for slow Sunday cooking, so the heading needs warmth and the body needs to disappear."* ## 3. Theme | Light mode | Dark mode | | ------------------------------------------------- | ----------------------------------------------- | | Finance, healthcare, legal, professional services | Premium consumer, gaming, music, creative tools | | Content-heavy (longform, blogs, docs) | Image/video-heavy (galleries, portfolios, film) | | Older / conservative audiences | Younger / tech-savvy / design-conscious | | Trust + reliability brands | Exclusivity + drama + sophistication brands | **The dual-mode toggle** is the safe answer but the laziest — every site that offers both feels less committed than sites that pick a side. Pick a side. **Cohesion constraint:** match the app's default. The scaffold ships a dark theme by default; if you've already switched the app's `@theme` block to light, the landing follows suit. **Pick rule:** light or dark, one sentence on why. Example: *"Dark — the product is a music tool used in dim studios at 1am, light mode would be a lie."* ## 4. Art direction Pick **one** archetype. Mixing two is occasionally a deliberate choice and almost always a sign of "couldn't decide." | Archetype | Look | Use for | | -------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | **Modern minimalism** | Generous whitespace, refined type, subtle motion, 1 accent color, character through type and spacing | Productivity, premium SaaS, design-conscious products | | **Editorial** | Serif headlines, grainy textures, asymmetric grids, magazine-style hierarchy | Content products, longform, lifestyle, hand-crafted | | **Brutalism / anti-design** | Clashing type, raw grids, exaggerated whitespace, deliberate "ugly" | Creative agencies, indie tools, fashion — never B2B finance | | **Bento modular** | Asymmetric grid of varying-sized cards, 12–24px gaps, size = hierarchy | Multi-feature SaaS (used by roughly two-thirds of top SaaS landings) | | **Glassmorphism / liquid glass** | Translucent layers, backdrop blur, soft inner shadows | Premium consumer, dashboards, modern productivity | | **Neo-brutalism** | Hard borders, hard offset shadows (no blur), saturated fills | Indie SaaS, bold marketing, anti-corporate | | **Kinetic typography** | Oversized animated type, scroll-driven word reveals, type IS the hero | Manifestos, agencies, writing tools | | **Retro / Y2K / 80s** | Period palettes, grain, gradient mesh, deliberate nostalgia | Gaming, music, niche cultural products | | **Hand-drawn / illustrated** | Custom illustrations, brushstrokes, paper textures (anti-AI signal) | Education, kids, sustainability, indie consumer | **Pick rule:** name the archetype + one sentence on what it means for *this* product. Example: *"Editorial — generous Fraunces serif type, paper grain background, asymmetric two-column body, decorative section dividers."* ## 5. Motion personality | Personality | Look | Suits | | -------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------- | | **Stillness** | Almost no motion. Single fade-in on load. Nothing animates after | Meditation, editorial, premium content | | **Subtle drift** | Slow scroll-triggered fades, small staggers, no large translations | Most B2B SaaS — the safe default | | **Cinematic** | Choreographed page-load reveal, parallax, large hero sequences | Editorial, agencies, story-driven products | | **Kinetic typography** | Letters animate in, words morph on scroll/hover | Manifesto sites, products where the headline IS the experience | | **Playful bouncy** | Spring physics, wobbles, rotations, joyful overshoots | Kids, indie consumer, games | | **Mechanical / technical** | Linear easings, instant snaps, terminal cursors, monospace tickers | Dev tools, technical dashboards | **Universal rule:** if motion doesn't guide, confirm, or clarify, cut it. Subtle is better than spectacular for roughly 80% of products. **Pick rule:** name the personality + one sentence on why. Example: *"Stillness — the product is about the long exhale after a hard conversation; nothing should move."* ## 6. Voice — three behaviors, not adjectives "Friendly," "professional," "modern" are categories, not commitments. Different writers do different things with each. Write **behaviors** instead. Examples of concrete voice behaviors: * Uses second-person ("you"), never first-person plural ("we") * Sentences max 12 words * Always starts with a verb * No em dashes — periods only * Contractions yes / contractions no * No exclamation points anywhere * Pun policy: dry only / never / freely * Opens every section with a question Reference voices to anchor against: * **Linear-ish:** confident, terse, technically literate, no hype * **Stripe-ish:** calm, precise, infrastructural, restrained excitement * **Mailchimp-ish:** warm, irreverent, occasionally weird, friendly humor * **Apple-ish:** aspirational, pared-back, subject-verb * **Patagonia-ish:** earnest, principled, story-driven, never salesy * **The New Yorker-ish:** lyrical, literary, generous sentences **Pick rule:** write three behaviors as a single semicolon-separated line. Example: *"Uses second-person; max 12-word sentences; never starts with 'we'."* ## Downstream choices — derived, not committed These follow naturally from the six commits above. You don't need to write them down, but stay consistent throughout the page. * **Border radius.** Sharp (0px) = formal, brutalist, professional services. Soft (4–8px) = SaaS default. Pillowy (12–24px) = friendly consumer. Pill (`9999px`) = buttons and tags only. Pick ONE radius scale and use it everywhere. * **Iconography.** Outline (`lucide-react` default) = minimal SaaS. Filled = bold CTAs and decisive moments. Duotone = brand color injection. Pick one source AND one style — never mix Lucide outline with custom filled icons in the same nav. * **Spacing.** 8px grid system. All spacing values are multiples of 8 (8, 16, 24, 32, 48, 64, 96, 128). Bento grids use 12–24px specifically. * **Density.** Sparse (premium, editorial), balanced (most SaaS), or dense (dev tools, dashboards). Pick one and apply throughout — every section obeys the same density. * **Imagery style.** Custom illustration (anti-AI authenticity), 3D sculptural (modern bold), atmospheric photography (mood-driven), code-based React mockups (**required** for product UI — never AI images), abstract geometric (data products), or no imagery at all. Pick what fits the art direction. * **Surface treatment.** Flat with borders, soft shadows, glass/blur, paper grain, neo-brutalist hard shadows, or gradient mesh. ONE treatment per page. Source: /design/style-tile.md --- # Pattern library Section-by-section landing page patterns - navigation, hero, features, social proof, CTA, footer, and scroll motion - with the recommended default of each in full. Proven structures for each landing page section. Pick sections within the [composition budget](/design/overview#the-composition-budget), choose **after** committing to a [Direction](/design/direction), then adapt each pattern's content and visual tokens to serve it. The pattern is the structure; your Direction is the soul. Labels such as `N1`, `H1`, and `F1` are stable identifiers — use them when discussing or reviewing a composition. Snippets assume placement in `src/pages/index.tsx`; adjust relative imports if you extract components. ## How patterns integrate with the scaffold * **Clean primitives** you can import from `src/components/landing/primitives.tsx`: `Typewriter`, `ScrollReveal`, `StaggerContainer`, `staggerChild`, `AnimatedStat`, `cn`, `motion`, `AnimatePresence`, `useInView`, `ChevronDown`. * `GlassCard`, `PlaceholderImage`, `BrowserMockup`, and `SectionHeading` contain known [gate](/design/anti-ai-gate) violations. Prefer inline semantic surfaces, or repair `primitives.tsx`. * **CTA routing:** CTAs navigate to `/home`, which is public in the scaffold. Target a route under `(app)/(protected)/` when sign-in should be required. No "landing seen" storage flag exists. ## Universal rules * Use semantic tokens; replace every `TODO`; use icons or inline SVG rather than pictograph emoji. * Wrap the tree in `<MotionConfig reducedMotion="user">`; manually gate scroll transforms, timer loops, and CSS keyframes ([rule 13](/design/overview#hard-rules-non-negotiable)). * Run the [anti-AI gate](/design/anti-ai-gate) after composition. * After you compose, eyeball-check that the page serves its Direction. If it does not, revise the Direction or the composition rather than adding more patterns. ## Navigation — 6 patterns, pick one (or none) If your landing lives under `(app)/`, apply the [nav-hiding patch](/design/overview#hide-the-global-navigation-on-the-landing-route) **before** dropping in any pattern below — otherwise the app's global `<Navigation />` stacks on top of the landing chrome, the clearest telltale of a bolted-on landing. A static top-level `src/pages/index.tsx` sits outside that layout and inherits no app chrome. ### N1 — Dual-state floating pill (recommended default) When to use: most modern SaaS, productivity tools, consumer products. The default workhorse — pick this unless your direction calls for something specific. Three coordinated pieces: a static top nav at page top, a floating pill that materializes on scroll, and an animated mobile dropdown. Active section highlighting works in both desktop states. **Direction → choice:** the "pill materializes on scroll" pattern reads as polish-conscious and modern. If your direction is editorial/zine (no polish theater) or brutalism (rejects smooth transitions), pick N5 or N3 instead. Full source - N1 dual-state floating pill ```tsx import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Menu, X } from 'lucide-react' import { AnimatePresence, motion, cn, } from '../components/landing/primitives' const NAV_SECTIONS = [ { id: 'features', label: 'Features' }, { id: 'pricing', label: 'Pricing' }, { id: 'faq', label: 'FAQ' }, ] as const const APP_NAME = 'TODO: Brand' // Small inline useActiveSection — the scaffolded LandingPage.tsx defines // one but doesn't export it. Reads from the document scroll, not a custom // scroll root, so element rects use viewport coords directly. function useActiveSection(ids: readonly string[]) { const [active, setActive] = useState<string | null>(null) useEffect(() => { const calc = () => { const triggerY = window.innerHeight * 0.3 let cur: string | null = null for (const id of ids) { const el = document.getElementById(id) if (el && el.getBoundingClientRect().top <= triggerY) cur = id } setActive(cur) } calc() window.addEventListener('scroll', calc, { passive: true }) return () => window.removeEventListener('scroll', calc) }, [ids]) return active } export function LandingNav() { const [isScrolled, setIsScrolled] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) const navigate = useNavigate() const ids = NAV_SECTIONS.map(s => s.id) const active = useActiveSection(ids) useEffect(() => { const onScroll = () => setIsScrolled(window.scrollY > 80) onScroll() window.addEventListener('scroll', onScroll, { passive: true }) return () => window.removeEventListener('scroll', onScroll) }, []) const scrollTo = (id: string) => { setMobileOpen(false) document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) } // /home is public by default in the scaffold. To force sign-in on click, // either swap to an `(app)/(protected)/<page>` route or open <AuthOverlay> here. const enterApp = () => navigate('/home') const mobileDropdown = ( <AnimatePresence> {mobileOpen && ( <motion.div initial={{ opacity: 0, y: -8, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -8, scale: 0.95 }} transition={{ duration: 0.2 }} className="md:hidden mt-2 rounded-2xl overflow-hidden bg-card/95 backdrop-blur-xl border border-border shadow-lg" > <div className="p-2 flex flex-col gap-0.5"> {NAV_SECTIONS.map(link => ( <button key={link.id} onClick={() => scrollTo(link.id)} className={cn( 'px-4 py-2.5 rounded-xl text-sm font-medium text-left transition-colors', active === link.id ? 'text-foreground bg-muted' : 'text-muted-foreground hover:text-foreground hover:bg-muted/70', )} > {link.label} </button> ))} <div className="h-px bg-border my-1" /> <button onClick={enterApp} className="px-4 py-2.5 rounded-xl text-sm font-medium text-left text-primary hover:bg-muted/70"> Get Started </button> </div> </motion.div> )} </AnimatePresence> ) return ( <> {/* Static top nav (page top; fades out on scroll) */} <motion.div className="absolute top-0 left-0 right-0 z-50" animate={{ opacity: isScrolled ? 0 : 1 }} transition={{ duration: 0.3 }} style={{ pointerEvents: isScrolled ? 'none' : 'auto' }} > <div className="max-w-6xl mx-auto px-6 py-5 flex items-center justify-between"> <span className="font-semibold text-lg tracking-tight text-foreground">{APP_NAME}</span> <div className="flex items-center gap-4"> <div className="hidden md:flex items-center gap-8"> {NAV_SECTIONS.map(link => ( <button key={link.id} onClick={() => scrollTo(link.id)} className={cn( 'text-sm font-medium transition-colors', active === link.id ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', )} > {link.label} </button> ))} </div> <button onClick={enterApp} className="hidden md:inline-flex items-center px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 active:scale-[0.97] transition-transform" > Get Started </button> <button className="md:hidden text-muted-foreground hover:text-foreground" onClick={() => setMobileOpen(p => !p)} aria-label="Toggle menu" > {mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />} </button> </div> </div> <div className="max-w-6xl mx-auto px-6">{mobileDropdown}</div> </motion.div> {/* Floating pill (slides down on scroll) */} <AnimatePresence> {isScrolled && ( <motion.nav className="fixed top-4 inset-x-0 z-50 flex justify-center pointer-events-none" initial={{ y: -80, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -80, opacity: 0 }} transition={{ duration: 0.4, ease: [0.25, 0.4, 0.25, 1] }} > <div className="pointer-events-auto flex items-center gap-1 px-2 py-1.5 rounded-full bg-background/80 backdrop-blur-2xl border border-border shadow-lg"> <span className="text-foreground font-semibold text-sm px-3 whitespace-nowrap">{APP_NAME}</span> <div className="w-px h-4 bg-border mx-1 hidden md:block" /> <div className="hidden md:flex items-center gap-0.5"> {NAV_SECTIONS.map(link => ( <button key={link.id} onClick={() => scrollTo(link.id)} className={cn( 'px-3.5 py-1.5 rounded-full text-sm font-medium transition-colors', active === link.id ? 'text-foreground bg-muted' : 'text-muted-foreground hover:text-foreground hover:bg-muted/70', )} > {link.label} </button> ))} </div> <button onClick={enterApp} className="ml-1 px-3.5 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 active:scale-[0.97] transition-transform" > Get Started </button> </div> </motion.nav> )} </AnimatePresence> </> ) } ``` ### The other five nav patterns * **N2 — Sticky docs-style top bar.** Dev tools, doc-heavy products, anything where the nav needs to persist and feel functional rather than decorative. No transformation on scroll — a solid `sticky top-0` bar with backdrop blur, a mono wordmark, a few text links, and a "Launch app →" text button. * **N3 — Corner brand, no nav at all.** Manifesto sites, single-screen kinetic-typography landings, retro directions. When the page is so committed to a single idea that a nav would diminish it: just two `fixed` corner elements — the brand top-left, an "Enter →" button top-right — each fading in on a delay. * **N4 — Hamburger-only.** Consumer products with strong identity where the nav is a secondary concern. Fixed brand top-left, a round bordered hamburger button top-right on every device; opening it covers the page with a `backdrop-blur` overlay of oversized centered links that stagger in. * **N5 — Inline anchor list (editorial).** Magazine, zine, or single-page long-scroll pages where the nav is prose-adjacent. No fixed bar, no pill — a masthead row (italic serif brand, issue label, heavy bottom border) with a small mono-uppercase anchor index beneath it that scrolls with the page. * **N6 — Hover-panel mega menu.** Product suites with enough surface area that a flat nav wouldn't fit. Each top-level item opens a categorized panel on hover (label + one-line description per entry). **Direction → choice:** only pick N6 if the product actually has 2+ top-level categories. A two-page product using a mega menu looks bigger than it is and reads as try-hard. ## Hero — 5 patterns, pick one * **H1 — Split-screen with animated product mockup.** Product-led SaaS where a UI preview is the easiest way to explain the thing. Text on one side, a live-rendered React mockup on the other — the mockup is a React component (staggered tiles, styled divs), never an AI-generated image. * **H2 — Full-bleed atmospheric.** Consumer brands, lifestyle and editorial products, products whose value is mood more than feature. Full source below. * **H3 — Bento hero.** Multi-feature SaaS where the first viewport should already communicate 3–5 things. A headline tile (spanning 4 columns and 2 rows) anchors the grid; the rest fills with a stat tile, an inline-visual tile, a pull-quote tile, and an inverted info tile — each small and distinct, not three identical cards. * **H4 — Typographic poster.** Manifesto sites, writing products, agencies. The headline IS the hero: `text-[12vw]` serif type on a near-empty canvas with one accent-colored italic word, closed by a bordered baseline row holding one short supporting sentence and a mono CTA. * **H5 — Live terminal / CLI demo.** Dev tools, APIs, technical infrastructure. A fake terminal types commands with realistic variable timing — slower keystrokes on input lines with random jitter, near-instant output lines — so it reads as convincing, not scripted. **The H5 reduced-motion lesson:** the terminal's typing loop is driven by `setTimeout`, which is not framer-motion — `<MotionConfig reducedMotion="user">` does not cover it. The pattern must call `useReducedMotion()` itself and short-circuit, jumping straight to the terminal's end state. The same applies to any timer or `requestAnimationFrame` loop ([rule 13](/design/overview#hard-rules-non-negotiable)). ### H2 — Full-bleed atmospheric (full source) A generated atmospheric image fills the viewport; the headline floats over a gradient scrim. **Image-generation workflow:** generate the image with `integration.post('freepik/generate-image-flux-dev', ...)` (or `gemini/generate-image`, `openai/generate-image`). **Your prompt must include `no text, no words, no letters, no writing, no logos`** — AI models hallucinate gibberish text otherwise. Persist the URL with `useR2Files` if you want it stable across renders. Generated images are for atmosphere only — product mockups stay React components. Full source - H2 full-bleed atmospheric ```tsx import { useNavigate } from 'react-router-dom' import { motion } from '../components/landing/primitives' const HERO_BG = 'TODO: paste integration-generated image URL here' export function AtmosphericHero() { const navigate = useNavigate() return ( <section className="relative min-h-[90vh] overflow-hidden"> <img src={HERO_BG} alt="" className="absolute inset-0 w-full h-full object-cover" /> <div className="absolute inset-0 bg-gradient-to-b from-background/40 via-background/30 to-background" /> <div className="relative z-10 max-w-4xl mx-auto px-6 pt-36 pb-24 text-center"> <motion.h1 initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.2 }} className="text-5xl md:text-7xl font-serif italic text-foreground leading-[1.02]" > TODO: 3–8 word headline. </motion.h1> <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6, duration: 0.6 }} className="mt-6 text-lg text-muted-foreground max-w-xl mx-auto" > TODO: one sentence. </motion.p> <motion.button initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.9 }} onClick={() => navigate('/home')} className="mt-10 inline-flex items-center px-7 py-3.5 rounded-full bg-foreground text-background text-sm font-medium hover:opacity-90" > Enter → </motion.button> </div> </section> ) } ``` ## Features — 5 patterns, pick one (sometimes two) Three identical cards with icon + title + description is the most-common AI-generated layout tell. Every pattern here is designed to break that shape — if your features section renders three of the same thing with the same structure, redesign it. ### F1 — Tabbed interactive showcase (full source) When to use: 3–5 features, each of which needs a visual. One tab list, one preview area. Clicking a tab swaps the preview. Full source - F1 tabbed interactive showcase ```tsx import { useState } from 'react' import { motion, AnimatePresence, cn } from '../components/landing/primitives' const FEATURES = [ { id: 'speed', label: 'Speed', title: 'TODO headline.', body: 'TODO one sentence.' }, { id: 'sync', label: 'Sync', title: 'TODO headline.', body: 'TODO one sentence.' }, { id: 'share', label: 'Share', title: 'TODO headline.', body: 'TODO one sentence.' }, ] export function TabbedFeatures() { const [active, setActive] = useState(FEATURES[0].id) const feature = FEATURES.find(f => f.id === active)! return ( <section id="features" className="max-w-5xl mx-auto px-6 py-24"> <h2 className="text-3xl md:text-4xl font-bold text-foreground tracking-[-0.02em]"> TODO: section headline. </h2> <div className="mt-10 grid md:grid-cols-[220px_1fr] gap-8"> <ul className="flex md:flex-col gap-1 border-b md:border-b-0 md:border-r border-border md:pr-6"> {FEATURES.map(f => ( <li key={f.id}> <button onClick={() => setActive(f.id)} className={cn( 'w-full text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors', active === f.id ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted/50', )} > {f.label} </button> </li> ))} </ul> <AnimatePresence mode="wait"> <motion.div key={feature.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.2 }} className="rounded-2xl border border-border bg-card p-8 min-h-[260px]" > <span className="text-xs font-mono uppercase tracking-[0.2em] text-primary">{feature.label}</span> <h3 className="mt-2 text-2xl font-semibold text-foreground">{feature.title}</h3> <p className="mt-3 text-muted-foreground max-w-md">{feature.body}</p> {/* Add a feature-specific inline visual here — styled divs, SVG, not AI images. */} </motion.div> </AnimatePresence> </div> </section> ) } ``` ### The other four feature patterns * **F2 — Alternating visual rows.** 2–4 features where each deserves space. Rows alternate left/right (`md:flex-row-reverse` on odd rows) so the page has rhythm; each row pairs a `ScrollReveal`-wrapped inline visual with a label + headline + one-paragraph body sliding in from opposite directions. * **F3 — Bento feature grid.** 4–7 features, several of which fit in smaller tiles. A 6-column grid of `col-span` tiles where **size = hierarchy** — the largest tile is the primary feature. * **F4 — Single scrolling showcase.** One feature is so much more important than the rest that it deserves the whole section: mono label, one-sentence claim as the headline, a 16:9 inline visual, then three short supporting beats in a row. **Direction → choice:** F4 suits editorial, minimalist, and premium directions where restraint is the aesthetic. Avoid it if your Direction calls for "show the whole product at a glance" — use F3 instead. * **F5 — Code-block feature list.** Dev tools where each feature is a code snippet. Title + description on one side, a `<pre>` code sample on the other — the code IS the demo. ## Social proof — 4 patterns, pick at most one **Real proof only.** Use a social-proof section only if you actually have real social proof. Fake logos and fake testimonials are worse than no social proof at all. * **S1 — Logo row + single big stat.** You have a few real customer/user logos AND one memorable metric. Keep it spare — five logos max, one oversized number with a one-line explanation. * **S2 — Single pull quote.** You have one great quote from a real person. Weight a single quote with serif typography instead of padding out a 3-quote row. * **S3 — Metric trio.** Three meaningful numbers that tell a story together, using the scaffolded `AnimatedStat` primitive in a `StaggerContainer`. **Reduced-motion note:** `useCountUp` inside `AnimatedStat` uses `requestAnimationFrame`, and the scaffolded primitive doesn't gate it — if your users include people with vestibular sensitivity, either inline a gated version or accept that numbers count once on entry (usually acceptable). * **S4 — Marquee carousel.** You have a lot of real logos or testimonials and want to show breadth. **Use only if your direction tolerates continuous motion.** A horizontal infinite scroll (the array doubled so the loop appears continuous) is the most-common offender for reduced-motion regressions — the pattern must gate on `useReducedMotion` and freeze (`x: 0`, no `repeat: Infinity` transition) for those users. ## CTA — 3 patterns, pick one ### C1 — Contrast band (full source) When to use: the default closer. A full-width band that breaks the page's rhythm and makes the action feel decisive. Full source - C1 contrast band ```tsx import { useNavigate } from 'react-router-dom' import { ArrowRight } from 'lucide-react' import { ScrollReveal } from '../components/landing/primitives' export function ContrastBand() { const navigate = useNavigate() return ( <section className="bg-primary text-primary-foreground"> <div className="max-w-4xl mx-auto px-6 py-24 text-center"> <ScrollReveal> <h2 className="text-4xl md:text-5xl font-bold leading-tight tracking-[-0.02em]"> TODO: one-line close. </h2> <p className="mt-4 opacity-80 max-w-md mx-auto">TODO: one-line support.</p> <button onClick={() => navigate('/home')} className="mt-8 inline-flex items-center gap-2 px-7 py-3.5 rounded-full bg-background text-foreground text-sm font-medium group" > TODO: verb <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-1" /> </button> </ScrollReveal> </div> </section> ) } ``` ### The other two CTA patterns * **C2 — Centered glow.** Subtle close for editorial, minimalist, or premium directions. The page keeps the same background; a soft radial glow (`bg-primary/15 blur-3xl` circle behind the content) gives the CTA weight without a hard color break. Serif italic headline, single button. * **C3 — Asymmetric full-bleed.** Brutalist, editorial, or agency directions. Breaks the max-width container between heavy `border-y-2` rules: left-anchored giant serif type (`text-[10vw]`), right-anchored mono uppercase button, negative space between. ## Footer — 3 patterns, pick one ### FT2 — Column grid (full source) When to use: the default SaaS footer. Brand column + 2–4 link columns + a small attribution row. Full source - FT2 column grid ```tsx import { Github, Twitter, Mail } from 'lucide-react' const LINKS = { Product: ['Overview', 'Changelog', 'Pricing'], Company: ['About', 'Blog', 'Careers'], Resources: ['Docs', 'Community', 'Support'], } const SOCIALS = [ { icon: Github, href: 'TODO', label: 'GitHub' }, { icon: Twitter, href: 'TODO', label: 'Twitter' }, { icon: Mail, href: 'TODO', label: 'Email' }, ] export function ColumnFooter() { return ( <footer className="border-t border-border"> <div className="max-w-6xl mx-auto px-6 py-16 grid grid-cols-2 md:grid-cols-5 gap-10"> <div className="col-span-2"> <span className="font-semibold text-foreground">TODO: brand</span> <p className="mt-2 text-sm text-muted-foreground max-w-xs">TODO: one-line product description.</p> <div className="mt-5 flex items-center gap-2"> {SOCIALS.map(({ icon: Icon, href, label }) => ( <a key={label} href={href} aria-label={label} className="w-9 h-9 grid place-items-center rounded-lg bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/70 transition-colors" > <Icon className="w-4 h-4" /> </a> ))} </div> </div> {Object.entries(LINKS).map(([heading, items]) => ( <div key={heading}> <h4 className="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-4">{heading}</h4> <ul className="space-y-2"> {items.map(i => ( <li key={i}> <a href="#" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{i}</a> </li> ))} </ul> </div> ))} </div> <div className="border-t border-border"> <div className="max-w-6xl mx-auto px-6 py-4 text-xs text-muted-foreground flex justify-between"> <span>© {new Date().getFullYear()} TODO: brand</span> <a href="https://deep.space" className="hover:text-foreground">Built with DeepSpace</a> </div> </div> </footer> ) } ``` ### The other two footer patterns * **FT1 — Minimal mono.** Editorial, zine, or manifesto pages where the footer should disappear into the page. One line of mono-uppercase meta-info (brand, typefaces, year — a colophon) and one link, above a heavy top border. * **FT3 — Editorial masthead.** Magazine/editorial/zine pages. A footer masthead echoing the nav masthead — italic serif brand, issue number and date, "Edited by" credit — closing the "it's a printed issue" metaphor. ## Scroll & motion — 4 patterns, pick zero to N **Skip this section entirely unless your Design Direction calls for scroll choreography.** A quiet or still direction ships without any of these. Every pattern here uses `useTransform` from `useScroll` or a continuous animation loop. **Both bypass `<MotionConfig reducedMotion="user">`** — manual `useReducedMotion()` gates are required. * **SM1 — Parallax background layer.** Editorial or atmospheric directions where a slow layer shift behind content adds depth. Maps `scrollYProgress` to a `-12%` → `12%` background translate; when `useReducedMotion()` is true the range collapses to `0%`/`0%`. * **SM2 — Pinned section with stage progression.** Product walkthroughs — 3–5 stages advance as the user scrolls through a pinned section (`sticky top-0` inside a container `STAGES.length * 80vh` tall); each stage swaps opacity on the visual. Reduced-motion users see the final stage immediately. * **SM3 — Scroll progress indicator.** Long-form editorial pages where the user wants to know how far in they are. A thin fixed bar at the top of the viewport, `scaleX` driven by `scrollYProgress` with `origin-left`. * **SM4 — Word-by-word reveal heading.** Manifesto sites, writing products, kinetic-typography directions. The headline splits into words; each fades in as it enters the viewport (`useInView`, once). Gated: reduced-motion users get `duration: 0` and see the whole heading at once. ## Next steps * [Anti-AI gate](/design/anti-ai-gate) — run the full gate after composing. * [Worked examples](/design/worked-examples) — see the patterns adapted to five committed directions. Source: /design/patterns.md --- # Anti-AI gate The canonical pre-commit grep gate and the eyeball checklist that catch AI-generated design tells before they ship. Run this before finishing any landing page. The [hard-rules table](/design/overview#hard-rules-non-negotiable) defines what's forbidden and the fix for each rule; this page gives the canonical gate commands that catch violations mechanically. The installed `LandingPage.tsx` and `primitives.tsx` contain known rule 5/6 violations. Treat their gate hits as bugs in the app copy, not false positives. ## The grep gate — run before finishing From the app root, scoped to landing files: ```bash # ── Hardcoded colors — should never appear ─────────────────────────────────── grep -rnE "#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}\b" --include="*.tsx" src/pages/index.tsx 'src/pages/(app)/landing.tsx' src/components/landing/ 2>/dev/null grep -rnE "rgba?\([0-9]" --include="*.tsx" src/pages/index.tsx 'src/pages/(app)/landing.tsx' src/components/landing/ 2>/dev/null grep -rnE "\b(violet|indigo|purple|fuchsia|rose|amber|emerald|teal|cyan|sky|blue|green|red|orange|yellow|lime|pink)-[0-9]{3}" --include="*.tsx" src/pages/index.tsx 'src/pages/(app)/landing.tsx' src/components/landing/ 2>/dev/null # ── Fractional-opacity foreground patterns ─────────────────────────────────── grep -rnE "(bg|text|border)-foreground/(\[|[0-9])" --include="*.tsx" src/pages/index.tsx 'src/pages/(app)/landing.tsx' src/components/landing/ 2>/dev/null # ── Continuous animations — advisory; review each for a useReducedMotion gate grep -rnE "repeat:\s*Infinity|setInterval\(|requestAnimationFrame\(" --include="*.tsx" src/pages/index.tsx 'src/pages/(app)/landing.tsx' src/components/landing/ 2>/dev/null # ── Pictograph emojis (Unicode 1F000-1FFFF). Plain marks like ✓ ✗ → ★ are NOT # caught (they're in the BMP < 1F000) and are allowed as text glyphs. # Needs PCRE: use `rg "[\u{1F000}-\u{1FFFF}]" src/pages/index.tsx 'src/pages/(app)/landing.tsx' src/components/landing/` if `grep -P` is unavailable. grep -rnP "[\x{1F000}-\x{1FFFF}]" --include="*.tsx" src/pages/index.tsx 'src/pages/(app)/landing.tsx' src/components/landing/ 2>/dev/null # ── Template placeholder copy + generic marketing phrases ──────────────────── grep -rniE "My App|Welcome to [Mm]y|[Ll]orem [Ii]psum|Your DeepSpace app is running" --include="*.tsx" src/pages/index.tsx 'src/pages/(app)/landing.tsx' src/components/landing/ 2>/dev/null grep -rniE "streamline your|transform your|cutting.edge|state.of.the.art|next.generation|revolutionary|world.class|best.in.class|game.chang" --include="*.tsx" src/pages/index.tsx 'src/pages/(app)/landing.tsx' src/components/landing/ 2>/dev/null # ── Unfilled TODOs ─────────────────────────────────────────────────────────── grep -rnE "TODO[: ]" --include="*.tsx" src/pages/index.tsx 'src/pages/(app)/landing.tsx' src/components/landing/ 2>/dev/null # ── Illegal import from a read-only example composition ───────────────────── grep -rn "from.*landing-design/examples" --include="*.tsx" src/ 2>/dev/null ``` **Any hit is a bug to fix before shipping.** The third block (continuous animations) is advisory rather than a hard failure — review each hit for a `useReducedMotion` gate rather than deleting the animation. ## The eyeball checklist If the grep gate is clean but you're still unsure, run the eyeball checks: * Design Direction block (prose, not placeholders) is present at the top of the landing page file * Hero headline is 3–8 words * Hero has a commanding visual (mockup, atmospheric background, signature element), not just centered text * Features section is not 3 identical cards * Every image slot is filled (a real `integration.post('freepik|openai|gemini/generate-image...')` URL, an uploaded R2 asset, or a code-based React visual) * The page doesn't look like a generic purple-gradient SaaS landing page * The scaffolded landing sections have been replaced or substantively rewritten — not shipped as-is ## The root-cause rule If any eyeball check fails, the bug isn't in the grep gate — it's in the design. Go back to the [Design Direction block](/design/direction) and check: does the code actually serve the direction you wrote? Usually the answer is "the direction is too vague" or "I drifted from my own direction." Source: /design/anti-ai-gate.md --- # Product polish Designing the authenticated app surface: the home page decision procedure, theme creation, the Base UI primitives kit, feedback discipline, and the verification gate. This guide covers the dynamic home, theme, primitives, and interaction feedback — the product itself, not the marketing page. For a marketing, landing, or splash page, use the [landing workflow](/design/overview) instead. The scaffold UI and themes are placeholders, not a house style. Design from the product's layout, typography, density, and tone. The copilot template's sidebar/main/chat-dock structure stays, but its content and theme still need product-specific design. ## Home page and first-run state The scaffold has two front-of-house pages: * `src/pages/index.tsx` — the **static landing** at `/`. It lives at the top level of `src/pages/`, so no DeepSpace providers mount: no auth fetch, no WebSocket, and no data hooks. It's the marketing front door; design it with the [landing workflow](/design/overview), not this procedure. * `src/pages/(app)/home.tsx` — the **dynamic home** at `/home`, inside auth/record providers (including signed-out `allowAnonymous`). To put this surface at `/`, use `(app)/index.tsx` after removing the static landing; top-level `index.tsx` cannot use data hooks. Replace the `home.tsx` stub rather than extending it. Any `placeholder page` or `Your app goes here` hit means the home is unfinished. Build the home page with this decision procedure — in order, no skipping: Name the primary surface Board, list, feed, document. That surface — not a poster describing it — is home. Pick the home skeleton by what the product is, and declare it The skeletons: * `product-preview-first` — the real primary surface, rendered with sample/preview data for visitors * `data-forward` — a dashboard/grid of live numbers, statuses, streaks above the fold * `search-first` — a search bar + the list, single column * `split-hero` — one-line pitch on one side, the live product surface on the other * `single-column-narrative` — for content/reading apps Write the declaration as the **first line of `src/pages/(app)/home.tsx`, before any code**, then make the JSX agree with it: ```tsx src/pages/(app)/home.tsx /* home pattern: data-forward — today's habit grid above the fold */ ``` The [verification gate](#verify-with-a-smoke-test) requires this comment. Pick for the product, not implementation convenience; use the [landing pattern library](/design/patterns) only for section-level structure. Signed-in home = the primary surface Above the fold, with the user's real data. Signed-out home = the same surface in preview form Sample or read-only data with an inline sign-in CTA; never an empty auth gate or an icon/H1/button poster. Content bar App-specific H1, one-sentence purpose, primary action above the fold, and an actionable `EmptyState` for signed-in users with no data. Known AI tells to avoid: the "centered hero + three icon-title-description cards" layout (see the [features patterns](/design/patterns#features-5-patterns-pick-one-sometimes-two)) and its minimal cousin, "centered icon badge + H1 + tagline + single CTA." Both read as template output regardless of theme. ## Theme — create one for the app `slate` (`src/styles.css`) and `paper` (`src/themes.css`) are rendering examples, not product themes. Replace them before first deploy. Themes are `[data-theme="<id>"]` CSS blocks overriding the shadcn tokens, activated via `<html data-theme="...">` in `index.html`. Switching is one attribute change; no JS, no FOUC. **This is the retheming surface for 95% of cases — not `DeepSpaceThemeProvider`.** ### The standard path Design the product palette Background, foreground, card, primary (+foreground), secondary, muted, accent, border, ring. If unspecified, choose and state a one-line rationale. Add a theme block Copy the `paper` block in `src/themes.css`, rename the selector, set your colors. Light themes must keep `color-scheme: light;` so native form controls match. Register it Add an entry to the `THEMES` array in `src/themes.ts` (type safety + catalog), then set `data-theme="<your-id>"` in `index.html`. Shape Set `--radius` smaller for sharp/technical or larger for soft/friendly. Update the title and favicon Update `<title>` in `index.html` and replace the favicon. The defaults say "DeepSpace App". Wordmark and nav Rebuild the starter `Navigation.tsx` freely; restyle but retain the copilot `AppSidebar` shell and fixed-icon collapse. Preserve sign-in/out, `src/nav.ts` links, and the test ids `app-navigation`, `nav-sign-in-button`, `nav-user-name`. Set at least background, foreground, card, primary, secondary, accent, and ring. Edit the baseline `@theme` block in `styles.css` only when intentionally replacing the default rather than adding a theme. ### Shadows caveat Tailwind v4's `@theme` bakes baseline shadow values into compiled utilities, so runtime `[data-theme]` overrides of `--shadow-*` tokens can't fully cancel them. For per-theme shadows on your own components, use literal arbitrary classes (`shadow-[0_2px_8px_0_rgba(0,0,0,0.08)]`) or scope a small utility under your `[data-theme]` block, and verify in the browser that the shadow changes when you switch themes. ### When to use `DeepSpaceThemeProvider` / `applyDeepSpaceTheme` instead These are exported from `deepspace` (the root package — there is no `deepspace/theme` subpath) and drive the `--theme-*` CSS variables consumed by SDK components. They read from `--color-*` by default (`readThemeFromDOM`), so the token setup is usually enough and they just follow. Reach for them explicitly when an embedded subtree needs a different theme from the rest of the app. ### UI dark/light mode Light themes set `color-scheme: light` inside the theme block, so native form controls (calendar icons, scrollbars) match. The SDK also reads `data-ui-theme="dark" | "light"` on `<html>` to switch between `UI_TOKENS_DARK` and `UI_TOKENS_LIGHT` (see `applyUIThemeTokens`) — set this if the app supports a light/dark toggle distinct from the theme picker. ## No emoji in UI chrome Do not use emoji as app chrome (titles, nav, buttons, empty states, headers). Allowed emoji contexts: * **User-authored content** — messages, comments, posts. Users type what they type. * **Message reactions** — the reaction picker itself (the selectable set of thumbs-up, hearts, and so on). * **The user explicitly asks for emoji** ("add a grocery emoji to the header"). Otherwise use `lucide-react`, inline SVG, or text. Build wordmarks through font, weight, tracking, and case. ## UI primitives — use the scaffolded Base UI kit, never browser defaults The scaffold ships a copy-paste primitives kit in `src/components/ui/` (index at `src/components/ui/index.ts`), built on **Base UI** (`@base-ui/react` — headless, from the Radix/Floating-UI/MUI team) and styled entirely with the app's semantic theme tokens. The components are the app's own files — restyle or extend them freely; their *look* follows the theme tokens automatically. Overlay positioning, focus trapping, select label rendering, and nested-dialog stacking are already correct — do not hand-roll replacements, and never use browser-default controls (they ignore theme tokens and render as native widgets). | Use case | Use this | Don't use | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | Select one of N options | `Select` + `SelectTrigger`/`SelectValue`/`SelectContent`/`SelectItem` | `<select>` / `<option>` | | Menu / overflow / "…" actions | `DropdownMenu` + `Trigger`/`Content`/`Item` (+ `CheckboxItem`, `RadioItem`, `Separator`, `Sub`) | hacked `<select>`, raw `<ul>` dropdown | | Confirm ("Are you sure?") | **`ConfirmModal`** (dedicated confirmation primitive) | `window.confirm()` | | Modal dialog | `Modal` (simple controlled: `open`/`onClose`, `Modal.Header/Body/Footer`) or the `Dialog` family (`DialogTrigger`/`DialogContent`/… for triggers, nesting, custom composition) | positioned `<div>` hacks | | Prompt for a string | `Modal` (or `Dialog`) with an `Input` inside | `window.prompt()` | | Alerts / info banners | `useToast` for transient; inline token-styled banner (`border border-border bg-card` + lucide icon) for persistent | `window.alert()` | | Success/error toast feedback | app-local `useToast` — `success()` / `error()` / `warning()` / `info()` | `alert()`, inline console text, silent mutations | | Empty lists / no data | `EmptyState` (icon + title + description + action) | raw "No items" text | | Loading placeholders | `animate-pulse` divs on `bg-muted` sized like the content; `Button loading` for pending actions | blank screens, hand-rolled CSS spinners | | Form fields | `Input`, `Textarea`, `Label`, `Checkbox`, `Switch` | raw HTML equivalents | | Search box | `SearchInput` (wraps `Input` with search icon + clear) | raw `<input type="search">` | | Tabs | `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` | hand-rolled tab buttons | | Anchored popups | `Popover`, `PopoverTrigger`, `PopoverContent` | absolutely-positioned divs | | Tooltips | `Tooltip`, `TooltipTrigger`, `TooltipContent` — one app-level `TooltipProvider` (already mounted in `_app.tsx`, 200ms) owns the delay and groups nearby triggers so they switch instantly. Don't wrap individual tooltips in their own provider — a nested provider shadows the app-level one and breaks the grouping. Pass `delay` on a single `Tooltip` for one-off timing | `title=""` attribute | | Avatars | `Avatar`, `AvatarImage`, `AvatarFallback` | raw `<img>` | | Status pills | `Badge` | hand-rolled rounded divs | | Cards / tables / separators | No primitive — token-styled elements (`rounded-lg border border-border bg-card p-4`; styled `<table>` with `border-border` rows; `border-t border-border`) | hardcoded colors | **Critical import rule:** use the scaffold's local `src/components/ui`, not `deepspace`; the SDK does not export this app-local kit. Keep hooks such as `useToast` paired with the local providers mounted by `_app.tsx`. ### Toast and confirmed-write feedback discipline **`useToast` is the default feedback channel** for any mutation. `const { success, error, warning, info } = useToast()` then: * `success('Saved', 'Your changes have been saved.')` after the mutation resolves. Plain `create` / `put` / `remove` are fire-and-forget, so use the `*Confirmed` variant before toasting success on anything the user must trust. * `error('Failed to save', err.message)` in the `catch` — **only `*Confirmed` variants throw on a server denial**. Plain mutations never hit the catch; their rejections surface through `RecordProvider`'s `onWriteError` toasts, already wired in the scaffold's `(app)/_layout.tsx`. * No silent mutations — the user should always see confirmation. ### Base UI gotchas (already handled in the kit — don't undo them) * **Custom trigger elements use the `render` prop, not `asChild`:** `<DialogTrigger render={<Button>Open</Button>} />`. The kit's `Button` is a native `<button>` and works as a `render` target. * **`SelectValue` label rendering** — the kit derives an items map from its `SelectItem` children so the trigger shows the *label* (not the raw value) even before the popup ever opens. Render `SelectItem`s inline (direct children / `.map(...)` / fragments) — items inside your own wrapper component are invisible to the walk; pass `items={{ value: 'Label' }}` explicitly instead. Option values must be non-empty strings (`''` is the cleared state). * **Nested dialogs** — the kit passes `forceRender` on backdrops so a modal-in-modal deepens the scrim. Opening a `Dialog` from inside a `Modal` just works. * **Tabs active state** styles via `data-active` (not `data-selected`). * **Open/close animations** depend on the custom `animate-in`/`animate-out` utilities in `src/styles.css` (with `animation-fill-mode: both`). Don't remove that block; components animate via `data-[open]`/`data-[closed]`. ## Prop shapes you'll otherwise forget **`Button`** — has a built-in `loading` prop. Do not hand-roll `{pending && <Spinner />}` + `disabled={pending}`: ```tsx <Button loading={creating} onClick={handleCreate}>Create</Button> // variants: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' // sizes: 'default' | 'sm' | 'lg' | 'icon' ``` **`ConfirmModal`** — dedicated confirmation; use it instead of composing a dialog + footer + two buttons: ```tsx <ConfirmModal open={confirmOpen} onClose={() => setConfirmOpen(false)} onConfirm={handleDelete} title={`Delete task '${task.title}'?`} description="This cannot be undone." confirmText="Delete" // default 'Confirm' cancelText="Cancel" // default 'Cancel' variant="destructive" // default 'destructive' — pass 'default' for non-destructive confirms loading={deleting} /> ``` **`EmptyState`**: ```tsx <EmptyState icon={<Inbox />} title="No tasks yet" description="Create your first task to get started." action={{ label: 'New task', onClick: openCreate }} secondaryAction={{ label: 'Import', onClick: openImport }} // optional /> ``` **`AuthOverlay`** — render without `onClose` and gate with `!isSignedIn`. Returns `null` automatically when signed in or still loading: ```tsx <AuthOverlay providers={['google', 'github']} /> // providers optional — defaults to both ``` **`useToast`** — four-level API, plus a generic `toast({ type, title, description, duration })`: ```tsx const { success, error, warning, info, toast, dismiss, dismissAll } = useToast() success('Saved', 'Changes saved successfully.') error('Upload failed', err.message) ``` ## Interaction polish (free wins) * **Every async action** (mutate, upload, send): `Button loading={pending}` — it disables and shows the spinner. Use optimistic UI where the collection supports it. * **Every destructive action** (delete, remove, leave): `ConfirmModal` that names the item in the body ("Delete task 'Buy milk'?"), not a generic "Are you sure?". * **Every mutation**: follow the `useToast` and confirmed-write rules above. * **Every form**: inline validation next to the field, not a global banner. Use `Label` + `Input` + a small `<p>` with the error. * **Every list during initial load**: `animate-pulse` placeholder blocks (`bg-muted rounded-md`) shaped like the content. Never a blank screen with just "Loading…". * **Hover/focus states** on every clickable element — the primitives handle this; raw `<div onClick>` does not. The scaffold also ships `*:focus-visible` outlines in `styles.css` — keep them. * **Keyboard accessibility**: `Dialog`/`Modal`, `DropdownMenu`, and `Select` all handle Esc/arrow keys + focus trapping; roll-your-own usually doesn't. ## Verify with a smoke test After customizing home, theme, and primitives, extend `smoke.spec.ts` (see [Testing](/guides/testing)): * Home page renders the **real** H1 (assert the app-specific title, not the app id or "Welcome"). * The placeholder copy is **not** in the DOM (assert absence of "placeholder page"). * Primary CTA is visible and clickable. * At least one real primitive opens on interaction (e.g., a `DropdownMenu` opens; clicking Delete opens the `ConfirmModal`). * Page `<title>` is app-specific, not "DeepSpace". * Spot-check a mutation and assert a toast appears. * Keep the nav test hooks intact: `app-navigation`, `nav-sign-in-button`, `nav-user-name`. Before declaring done, run both halves: ```bash # Half 1 — ABSENCE: any hit below means the app is NOT ready grep -REn '<select|(^|[^[:alnum:]_.])(window\.)?(confirm|alert|prompt)[[:space:]]*\(' src/ grep -rn "placeholder page\|Your app goes here" src/ grep -rn 'data-theme="slate"' index.html # Half 2 — PRESENCE: any MISS below means the home page is NOT done grep "home pattern:" 'src/pages/(app)/home.tsx' # the skeleton declaration, first line (quote the path — parentheses) grep 'data-theme="' src/themes.css # your own theme block exists ``` Source: /design/product-polish.md --- # Worked examples Five complete landing page archetypes, each a filled Design Direction translated line-by-line into code, with the lesson each one teaches. Five reference compositions, each a filled [Design Direction](/design/direction) translated into a complete landing page. They exist to teach the translation — how a committed direction becomes concrete design choices — not to be copied. ## How to pick one Pick **one** archetype by **emotional adjacency, not product category**, then read exactly that one. A journaling app whose emotion is "Sunday kitchen warmth" should read the cooking example, not go hunting for a "journaling" template. Trace Direction → Style Tile → code; adapt the mechanism, never clone the composition. If no emotion matches, choose by metaphor, then by transferable signature mechanism. Example 05 focuses on the animated bento mockup; navigation and FAQ variants live in the [pattern library](/design/patterns). ## The five archetypes | # | Archetype | Emotion | Visual metaphor | Signature element | | -- | ------------------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | 01 | Cooking / warmth | Sunday kitchen warmth, nostalgic, tactile | A handwritten recipe card on a butcher-block table, morning light through a window | Torn-paper SVG section dividers | | 02 | Developer tool / precision | Sharp, confident, technical, precise | A blinking cursor in a dark server room, code compiling in real time | A live terminal in the hero that types real commands with realistic variable timing | | 03 | Meditation / calm | Spacious calm, weightless, present | The horizon line at dawn, the pause between breaths | A breath circle pulsing at 4.5s per cycle as the hero centerpiece | | 04 | Children's storybook / playful | Playful, imaginative, tactile, safe | A paper cut-out diorama, crayon scribbles on construction paper | Hand-drawn SVG elements that wobble + paper grain texture overlay | | 05 | SaaS / clarity | The Friday-afternoon recap that lets the laptop close | A one-page-per-week paper folio in a manager's bottom drawer | An animated bento dashboard mockup that runs once on entry: chart draws itself, status flips, recap paragraph types in | Each example's full Style Tile is in its Direction accordion below. ## How to read an example Read, in order: the Direction block, the signature implementation, the typography and color commitments, the motion personality — each section opens with its design lesson. Then close the example before composing your own page. ## 01 — Cooking / warmth ("The Sunday Pantry") **Lesson: repeated texture and imperfection can make structure feel handmade.** The tilted recipe card, the hand-drawn torn edges, and the slow steam are all "imperfections" placed deliberately — the page's structure is a conventional hero/features/quote/CTA/footer, but the texture makes it feel like an object. Filled Design Direction - example 01 **Product** — one sentence, what it does for whom. A weekly recipe club for people who want to cook dinner at home but are tired of decision fatigue. Every Sunday, three recipes land in your inbox, each with a shopping list and a story about where it came from. **Emotion** — one specific feeling, not a category. Sunday morning, second coffee, nowhere to be. The window is open, someone is making noise in the kitchen downstairs. The feeling of being fed by someone who took their time. **Metaphor** — a concrete real-world image. A handwritten recipe card on a butcher-block table next to a worn enameled mug. Morning light coming in at a low angle. A thumbprint of flour on the edge of the card. **Three references from outside the cooking-app category.** 1. Kinfolk magazine — warm off-white, generous whitespace, editorial photography, small serif captions, zero animation. Restraint. 2. Le Creuset product packaging — cream + terracotta, oversized serif product names, matte textures, the feeling of something heirloom. 3. Wes Anderson color palettes — symmetric compositions with one unexpected pop of color (a soft mint, a washed coral). **Signature element** — one thing that makes the page memorable. Torn-paper SVG dividers between sections. The torn edge is hand-drawn as a path, slightly uneven. Each section looks like a page torn from a notebook and placed on the table. **Hero visual** — concretely, what animates in the first 5 seconds. A slightly tilted "recipe card" rendered as styled divs with torn edges. The headline "Dinner, written by hand." sits on the card in a serif display font. Behind the card, two soft curved SVG paths rise slowly like steam from a mug just off-frame, then fade. **Style Tile** * Color: warm cream dominant, deep terracotta accent, muted everywhere except a single vivid accent on the CTA. (Matches the app's `@theme`.) * Type: Fraunces (serif, display) + Source Sans 3 (body). Fraunces has a variable warmth axis — we want the warm end. * Theme: light. This is a paper surface, not a screen. * Art direction: editorial. Magazine-style hierarchy, two-column body, small-caps labels, torn-paper dividers as the signature. * Motion: subtle drift. Scroll-triggered fades, small staggers. The steam above the hero is the only continuous animation, and it's 3s slow. * Voice: uses second-person; contractions yes; no em dashes; never starts with "we". **Sentence test** — could this direction describe any other product? No. The weekly cadence, handwritten feel, decision-fatigue frame, and "fed by someone who took their time" emotion — none transfer to a dev tool, meditation app, game, or fintech. Every choice serves this. The signature implementation — a hand-drawn torn edge as an SVG path (`TornPaperDivider`), reused between sections — is in the complete composition inlined [at the bottom of this page](#the-complete-example-01-end-to-end). ## 02 — Developer tool / precision ("runner") **Lesson: one accent, mono type, mechanical easing, and a single terminal make restraint the identity.** No decoration anywhere — the entire personality lives in typography, timing, and one convincing artifact. Filled Design Direction - example 02 **Product.** A CLI + cloud runner that watches your test suite, re-runs the subset affected by each save, and streams live results to a terminal pane on any laptop on the team. For teams whose monorepo is too big for local test runs and too hot for a manual CI gate. **Emotion.** The small confidence of seeing a green check scroll past three seconds after a save. The opposite of "I'll run the full suite overnight." **Metaphor.** A blinking cursor in a dark server room. The muffled hum of fans. One terminal pane, one tail command, everything you need to trust in one line. **Three references from outside dev tools.** 1. Teenage Engineering TX-6 mixer — one strip of controls, every dial has a purpose, nothing decorative. Monochrome aluminum. 2. Swiss railway signage — Frutiger Neue, consistent spacing, pictograms where text would slow you down. 3. The test-card patterns from an analog oscilloscope manual. **Signature element.** A live fake terminal in the hero that types real commands with realistic variable timing — pauses at punctuation, speeds through whitespace, occasional micro-stumbles — then streams back stylized green checkmarks. **Hero visual.** `$ tests --watch` appears one keystroke at a time. A 400ms pause. Then ` ✓ 1,204 passing · 3.2s` appears in primary-colored mono text, followed by a second line that flashes as a file saves. **Style Tile** * Color: near-black background, soft off-white foreground, one cyan-ish primary for status markers and CTAs. Zero gradients. * Type: IBM Plex Mono (heading + mono) + Inter (body). Mono headline is a deliberate statement: the product IS the terminal. * Theme: dark. The tool is used in dim rooms at 11pm. * Art direction: modern minimalism with a technical-minimalism tilt. Dense typography, small measured whitespace, no decoration. * Motion: mechanical. Linear easings, instant snaps, no bouncing. Only the terminal cursor pulses. * Voice: verb-first; no adjectives; contractions yes; max 10 words. **Sentence test** — could this describe any other product? No. The watch-and-stream framing, the "too big for local, too hot for CI" edge, the oscilloscope reference — no other tool, no other dev tool, no generic SaaS. The signature implementation — variable typing timing that reads as human, not scripted (input lines get jitter and punctuation pauses; output lines stream near-instantly), with the [manual reduced-motion short-circuit](/design/patterns#hero-5-patterns-pick-one) a timer loop requires: ```tsx useEffect(() => { if (reduce) { setLine(TERMINAL_SCRIPT.length); return } // jump to end state if (line >= TERMINAL_SCRIPT.length) return const cur = TERMINAL_SCRIPT[line] if (char >= cur.text.length) { const t = setTimeout(() => { setLine(n => n + 1); setChar(0) }, cur.pauseAfter ?? 160) return () => clearTimeout(t) } const c = cur.text[char] let delay = cur.type === 'in' ? 30 + Math.random() * 40 : 8 if ('.,:'.includes(c)) delay += 140 const t = setTimeout(() => setChar(n => n + 1), delay) return () => clearTimeout(t) }, [line, char, reduce]) ``` ## 03 — Meditation / calm ("Breathe.") **Lesson: a deliberately slow 4.5-second breath cycle makes motion serve calm instead of reading as a spinner.** A UX-speed rhythm (1s, 2s) would destroy the direction — the slowness is the point. Filled Design Direction - example 03 **Product.** A daily breathing app. One guided 4-7-8 breath cycle every morning, one minute long. No meditation library, no courses, no leaderboard. You open it, you breathe, you close it. **Emotion.** The long exhale after a hard conversation. The second before you open your eyes. Not "peaceful" — the specific weightlessness of the pause between breaths. **Metaphor.** The horizon line at dawn, before the sky has color in it. Flat. Still. Everything is still about to happen. **Three references from outside wellness apps.** 1. The inside of a Rothko chapel — large color fields, no figures, an absence that asks you to sit with it. 2. Japanese minimalist bookstore design — one object on a shelf, an ocean of whitespace, one spot of subtle green. 3. The opening shot of Tarkovsky's Solaris — a slow pan over reeds in still water, nothing happens, you can't look away. **Signature element.** A breath circle that pulses at 4.5 seconds per cycle as the hero centerpiece. 4.5 is slow — slower than a UI animation should be. The slowness is the point. A UX rhythm (1s, 2s) would make it read as a loading spinner and destroy the entire direction. **Hero visual.** A single soft-edged circle fills the center of the viewport. It scales from 0.9 to 1.1 over 4.5 seconds, then back, indefinitely. Behind it: nothing — just cream. A single serif word ("Breathe.") fades in beside it, not in it. **Style Tile** * Color: cream dominant, sage accent, desaturated everywhere. No vivid anything — even the CTA is `primary/60`. * Type: Cormorant (display serif, generous counters) + Lato (body). Cormorant has restraint Garamond doesn't. * Theme: light. The product is morning; light is right. * Art direction: modern minimalism. Generous whitespace, one object per viewport, refined type. * Motion: stillness. The breath circle is the only animation. Sections fade in once, then never move. * Voice: generous sentences; no urgency words; never rhetorical "why?". **Sentence test** — could this describe any other product? No. "One minute, then close it" defines the entire philosophy and rules out nearly all wellness apps. The 4.5-second breath circle and Tarkovsky reference make it this specific app and not a Calm clone. The signature implementation — the continuous pulse is gated per-element because `repeat: Infinity` bypasses `MotionConfig`: ```tsx <motion.div className="absolute inset-0 rounded-full border border-primary/20" animate={reduce ? undefined : { scale: [0.92, 1.08, 0.92], opacity: [0.6, 1, 0.6] }} transition={reduce ? undefined : { duration: 4.5, repeat: Infinity, ease: [0.45, 0.05, 0.55, 0.95] }} /> ``` ## 04 — Children's storybook / playful ("Story Box") **Lesson: coordinated wobble, imperfect SVG, grain, and rounded shapes make playfulness intentional.** Each element alone would look like a mistake; together — the wobbling hand-drawn dinosaur, the paper-grain overlay, the slightly rotated cards — they read as a deliberate handmade world. Filled Design Direction - example 04 **Product.** A bedtime storybook app for kids aged 3–7. Parents choose a theme (dinosaurs, space, grandma's kitchen), the app generates a short illustrated story, parent reads it aloud with the kid tracing words. Each story is 7 pages, \~120 words. **Emotion.** The giddy 7:30 PM energy before a bath and a story. A kid squeezing a worn paperback they've read 40 times. The specific feeling of construction-paper-and-crayon in a kindergarten classroom. **Metaphor.** A paper-cut-out diorama on a child's bedroom floor. Uneven scissor lines, tape on the back, a crayon smudge. Homemade, not polished. **Three references from outside kids apps.** 1. Eric Carle illustrations — torn tissue paper, primary colors on white, nothing precise about the edges. 2. The color script of Pixar's Up — warm yellows, coral reds, with a single quiet teal moment per scene. 3. Sanrio stationery — simple shapes, lots of whitespace, face on everything, tactile offset-printed look. **Signature element.** Hand-drawn SVG elements that wobble subtly on hover + a paper-grain texture overlay on the whole page. The wobble is the thing: it says "this was drawn by a person, not rendered by a template." **Hero visual.** A crooked paper cut-out of a dinosaur (inline SVG), wobbling gently. Behind it: torn-paper mountains in coral + warm yellow. A speech bubble next to it says "Read me a story?" in a round sans-serif. **Style Tile** * Color: warm yellow dominant, coral accent, one quiet teal detail. Saturation is high but not neon — think offset-printed zine, not Fruit Loops commercial. * Type: Nunito for everything. Round, friendly, reads well at 3rd-grade level. Exactly one font family — two is too grown-up. * Theme: light. This is daylight, this is before bed, this is a bedroom with a lamp on. * Art direction: hand-drawn / illustrated. Paper-grain overlay, wobbling SVG, mismatched-but-coordinated pastel shapes. * Motion: playful bouncy. Spring physics on hovers, wobbles on the dinosaur, a gentle bounce on the CTA. * Voice: second-person ("you"), questions that a kid would actually ask, max 8 words, zero marketing words. **Sentence test** — could this describe any other product? No. "Parents choose a theme" + "the kid traces words" + "7 pages, \~120 words" is specific to this app and couldn't describe a reading-tracker, a spelling-game, or any generic edtech product. The signature implementation — a hand-drawn inline SVG with a slow, gated wobble, plus an SVG-noise paper grain fixed over the whole page: ```tsx <motion.svg viewBox="0 0 220 220" className="w-56 h-56 md:w-72 md:h-72 text-primary" animate={reduce ? undefined : { rotate: [-3, 3, -3] }} transition={reduce ? undefined : { duration: 6, repeat: Infinity, ease: 'easeInOut' }} aria-hidden > {/* hand-drawn body path, imperfect on purpose */} </motion.svg> ``` ## 05 — SaaS / clarity ("Friday afternoon, in one screen") **Lesson: editorial hierarchy restrains a bento layout; the mockup animates once, then becomes still.** The hero's dashboard mockup runs a single choreographed sequence on load — a chart draws itself, a status pill flips, a recap paragraph types in — then the page goes quiet, "like a Polaroid finishing developing." This example demonstrates the bento hero with a once-only React product animation and a hierarchy-driven bento feature grid; use the pattern library for navigation, FAQ, CTA, and footer. Filled Design Direction - example 05 **Product.** A weekly-review app for small engineering teams (5–25 people). Every Friday at 3pm, the dashboard auto-fills: what shipped, what slipped, who was blocked, and one paragraph for the staff-eng to read Saturday before deciding next week's plan. Not a tracker. Not Jira. A recap. **Emotion.** The relief of finishing a Friday-afternoon planning thread with an actual decision instead of a "let's circle back Monday." The exact moment a manager closes the laptop and the weekend genuinely starts. **Metaphor.** A one-page-per-week paper binder kept in a manager's bottom drawer — the kind that fills with hand-written annotations over a year. Not another dashboard. Not another Slack channel. A folio. **Three references from outside SaaS / B2B / dev tools.** 1. An Eames-era weekly desk planner — everything important in one folio per week, generous whitespace, one accent ink color. 2. Ina Garten's prep-list pages — declarative, three categories, no decoration, the kind of clarity that makes a complex day feel small. 3. The print layout of The Economist's KAL editorial cartoon column — tight grid, dense information, one hairline accent. **Signature element.** An animated bento dashboard mockup in the hero where, on first view, ONE tile draws a chart line, ANOTHER tile flips a status pill from amber to moss-green, and the "Friday decision" card writes itself one word at a time over \~3 seconds. The page sits still after that — the mockup runs ONCE, like a Polaroid finishing developing. **Hero visual.** Split-screen. Left: "Friday afternoon, in one screen." in a clean sans at \~72px, supporting line, primary CTA. Right: the bento mockup, taking up most of the right column. As soon as the page loads, the chart line draws itself, the status flips, the recap text types in. Then it stops. **Style Tile** * Color: warm off-white dominant; deep ink-blue primary; one moss-green "status" accent. Muted everywhere — even the CTA is full-saturation but used sparingly. * Type: Inter (heading + body) + IBM Plex Mono (dashboard data). Inter as a deliberate choice: clarity beats personality for a recap tool. The mono in the mockup makes "this is data" obvious without ornament. * Theme: light. Friday afternoon, sun in a kitchen window. Dark-mode would be a lie about who uses this and when. * Art direction: bento-modular with editorial restraint. Bento for the hero mockup + features grid; magazine-style hierarchy elsewhere. * Motion: subtle drift. The bento mockup runs ONCE on load. After that, only fade-in-on-scroll. No marquees, no parallax, no continuous loops. * Voice: declarative; second-person; max 14 words; never starts with "we"; zero exclamation points. **Sentence test** — could this describe any other product? No. The Friday-3pm cadence, the "one paragraph for the staff-eng to read Saturday" specificity, the Eames/Garten/Economist reference set, and the once-only animation personality define THIS product. They don't transfer to Linear, Asana, Notion, or any analytics dashboard. The signature implementation — the chart tile draws itself exactly once (`pathLength` animation, no repeat), collapsing to instant for reduced-motion users: ```tsx <motion.polyline fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" points="0,50 30,42 60,46 90,32 120,30 150,18 180,12 200,8" initial={{ pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={reduce ? { duration: 0 } : { duration: 1.6, delay: 0.6, ease: 'easeInOut' }} /> ``` ## The complete example 01, end-to-end The most teachable full composition: every section serves the "Sunday kitchen warmth" direction — the editorial masthead-style hero header, the torn-paper dividers, the alternating rows with hand-built visuals, a single pull quote, a quiet glow CTA, and a colophon footer. In the real file, the filled Direction block above sits as a comment at the top. Full source - example 01, The Sunday Pantry ```tsx import { useRef } from 'react' import { useNavigate } from 'react-router-dom' import { MotionConfig, motion, useReducedMotion } from 'framer-motion' import { ArrowRight } from 'lucide-react' import { ScrollReveal } from '../components/landing/primitives' // ── Signature element: torn-paper SVG divider ──────────────────────────────── function TornPaperDivider({ flip = false }: { flip?: boolean }) { return ( <div className={`relative w-full h-6 ${flip ? 'rotate-180' : ''}`} aria-hidden> <svg className="absolute inset-0 w-full h-full text-background" viewBox="0 0 1200 24" preserveAspectRatio="none"> <path d="M0,0 L0,14 L60,10 L130,18 L220,8 L310,16 L400,6 L490,14 L580,10 L670,18 L760,8 L850,14 L940,6 L1030,16 L1120,10 L1200,18 L1200,0 Z" fill="currentColor" /> </svg> </div> ) } // ── Hero steam (continuous animation — gated on reduced motion) ────────────── function RisingSteam() { const reduce = useReducedMotion() if (reduce) return null return ( <svg className="absolute -top-10 right-8 w-16 h-28 text-primary/30" viewBox="0 0 64 112" aria-hidden> {[0, 1].map(i => ( <motion.path key={i} d={`M${22 + i * 14},100 C${18 + i * 14},80 ${30 + i * 14},60 ${22 + i * 14},40 C${16 + i * 14},20 ${28 + i * 14},10 ${22 + i * 14},0`} stroke="currentColor" strokeWidth={1.5} fill="none" strokeLinecap="round" initial={{ opacity: 0, y: 10 }} animate={{ opacity: [0, 0.8, 0], y: [20, -20] }} transition={{ duration: 3, repeat: Infinity, delay: i * 1.5, ease: 'easeOut' }} /> ))} </svg> ) } // ── Hero ───────────────────────────────────────────────────────────────────── function Hero() { const navigate = useNavigate() return ( <section className="max-w-5xl mx-auto px-6 pt-12 pb-20"> <div className="flex items-baseline justify-between border-b-2 border-foreground pb-3"> <span className="font-serif italic text-xl text-foreground">The Sunday Pantry</span> <span className="hidden sm:block font-mono text-[11px] uppercase tracking-[0.28em] text-muted-foreground"> Weekly · No. 14 </span> </div> <div className="relative mt-16 grid md:grid-cols-[1.4fr_1fr] gap-10 items-center"> {/* The tilted "recipe card" — the hero's commanding visual. */} <motion.div initial={{ opacity: 0, y: 10, rotate: -2 }} animate={{ opacity: 1, y: 0, rotate: -2 }} transition={{ duration: 0.8, ease: [0.22, 0.9, 0.3, 1] }} className="relative bg-card border border-border shadow-lg p-8 md:p-12" style={{ transform: 'rotate(-2deg)' }} > <RisingSteam /> <span className="font-mono text-[11px] uppercase tracking-[0.3em] text-primary">Recipe № 42</span> <h1 className="mt-4 font-serif text-5xl md:text-7xl leading-[0.95] text-foreground"> Dinner, written by hand. </h1> <p className="mt-6 max-w-md text-base text-muted-foreground leading-relaxed"> Three recipes every Sunday, each with a shopping list and a story. Cook better weeknights without having to decide. </p> <button onClick={() => navigate('/home')} className="mt-8 inline-flex items-center gap-2 px-5 py-2.5 rounded-none border-2 border-foreground bg-foreground text-background text-sm font-medium group hover:bg-primary hover:border-primary transition-colors" > Start cooking <ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-0.5" /> </button> </motion.div> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3, duration: 0.7 }} className="hidden md:block font-serif text-2xl italic leading-relaxed text-muted-foreground" > <p>Like a recipe card your aunt sent you.</p> <p className="mt-4">With a thumbprint of flour.</p> </motion.div> </div> </section> ) } // ── What you get (alternating rows, editorial tone) ────────────────────────── const ROWS = [ { label: 'Sundays', title: 'Three recipes, every week.', body: 'Pulled from home kitchens — not food blogs. Each comes with the grocery list already built.', }, { label: 'Stories', title: 'A paragraph of where it came from.', body: 'Whose kitchen, whose Sunday, which complaints it was invented to fix. Reading it makes you want to cook it.', }, ] function WhatYouGet() { return ( <section id="features" className="bg-muted py-20 md:py-28"> <div className="max-w-5xl mx-auto px-6 space-y-20"> {ROWS.map((row, i) => ( <div key={row.title} className={`flex flex-col ${i % 2 ? 'md:flex-row-reverse' : 'md:flex-row'} gap-10 md:gap-16 items-center`}> <ScrollReveal direction={i % 2 ? 'right' : 'left'} className="flex-1 w-full"> {/* Tiny hand-drawn visual — styled divs, no AI image. */} <div className="aspect-[5/4] bg-card border border-border relative overflow-hidden"> <div className="absolute inset-6 border border-border p-4"> <div className="h-2 w-2/3 bg-foreground" /> <div className="mt-3 space-y-1.5"> {[68, 52, 78, 44].map((w, k) => ( <div key={k} className="h-[2px] bg-border" style={{ width: `${w}%` }} /> ))} </div> <div className="mt-5 font-serif italic text-lg text-primary">{i === 0 ? '— turns out chicken —' : '— she called it Monday stew —'}</div> </div> </div> </ScrollReveal> <ScrollReveal direction={i % 2 ? 'left' : 'right'} delay={0.1} className="flex-1 max-w-md"> <span className="font-mono text-[10px] uppercase tracking-[0.3em] text-primary">{row.label}</span> <h3 className="mt-2 font-serif text-3xl md:text-4xl leading-tight text-foreground">{row.title}</h3> <p className="mt-4 text-muted-foreground leading-relaxed">{row.body}</p> </ScrollReveal> </div> ))} </div> </section> ) } // ── Single pull quote ──────────────────────────────────────────────────────── function PullQuote() { return ( <section className="max-w-3xl mx-auto px-6 py-28"> <ScrollReveal> <blockquote> <p className="font-serif italic text-3xl md:text-4xl leading-[1.25] text-foreground"> “I canceled my meal kit. I’m actually cooking now, on purpose.” </p> <cite className="mt-6 block not-italic font-mono text-[11px] uppercase tracking-[0.25em] text-muted-foreground"> — Ellis M. · subscriber since week 4 </cite> </blockquote> </ScrollReveal> </section> ) } // ── Quiet CTA ──────────────────────────────────────────────────────────────── function CTA() { const navigate = useNavigate() return ( <section className="relative py-24"> <div className="absolute inset-0 grid place-items-center pointer-events-none" aria-hidden> <div className="w-[600px] h-[600px] rounded-full bg-primary/15 blur-3xl" /> </div> <ScrollReveal className="relative max-w-2xl mx-auto px-6 text-center"> <h2 className="font-serif italic text-4xl md:text-5xl text-foreground"> Cook something good on Tuesday. </h2> <p className="mt-4 text-muted-foreground">First issue lands this Sunday.</p> <button onClick={() => navigate('/home')} className="mt-8 inline-flex items-center gap-2 px-6 py-3 rounded-none border-2 border-foreground bg-foreground text-background text-sm font-medium hover:bg-primary hover:border-primary transition-colors" > Join the club → </button> </ScrollReveal> </section> ) } // ── Editorial masthead footer ──────────────────────────────────────────────── function Footer() { return ( <footer className="mt-12 border-t-2 border-foreground"> <div className="max-w-5xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-baseline justify-between gap-2"> <span className="font-serif italic text-lg text-foreground">The Sunday Pantry</span> <p className="font-mono text-[10px] uppercase tracking-[0.3em] text-muted-foreground"> Set in Fraunces & Source Sans · delivered by email · cancel anytime </p> </div> </footer> ) } // ── Page ───────────────────────────────────────────────────────────────────── export default function LandingPage() { const containerRef = useRef<HTMLDivElement>(null) return ( <MotionConfig reducedMotion="user"> <div ref={containerRef} className="min-h-screen bg-background text-foreground"> <Hero /> <TornPaperDivider /> <WhatYouGet /> <TornPaperDivider flip /> <PullQuote /> <CTA /> <Footer /> </div> </MotionConfig> ) } ``` ## The examples are read-only Do not ship installed sections verbatim, and never copy an example composition wholesale into your app — the [grep gate](/design/anti-ai-gate)'s illegal-import check exists to catch exactly this. **Direction owns the result; patterns supply structure; examples demonstrate another product's translation.** Adapt the mechanism — the torn divider, the variable typing timing, the once-only choreography — to serve *your* Direction. ## Why only five Five archetypes limit menu-driven cloning. If none fits, use an external landing page, magazine spread, or product photograph that shares the emotion — the point is always emotional adjacency, never a template catalog. Source: /design/worked-examples.md --- # SDK reference Every export from the deepspace package, with signatures and examples. The `deepspace` package exposes five main entry points (plus two for the documentation feature). Each section of this reference documents the surface of one entry point, organized by feature. ```ts import { ... } from 'deepspace' // React client SDK import { ... } from 'deepspace/schema' // Runtime-neutral schemas (browser + worker safe) import { ... } from 'deepspace/worker' // Cloudflare Worker runtime import { ... } from 'deepspace/server' // Platform-backed server helpers import { ... } from 'deepspace/testing' // Playwright fixture (test files only) ``` **Choosing between `deepspace/schema` and `deepspace/worker` for schemas:** `deepspace/schema` is the runtime-neutral entry - import from it for any schema (or schema type) that browser or shared code touches, so neither runtime's surface leaks into the other bundle. Reserve `deepspace/worker` for worker-only helpers (DO classes, auth verification, metering). Every name `deepspace/schema` exports is also available from `deepspace/worker`, so worker-only files may keep importing schemas from `deepspace/worker`. ## Client - `deepspace` Everything you import on the frontend - providers, hooks, components, and utility functions. | Section | Covers | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | [Auth](/sdk-reference/client/auth) | `useAuth`, `useUser`, `useAuthStatus`, `useAuthProfileReady`, `AuthGate`, `AuthOverlay`, `signIn`, `signOut`, `getAuthToken` | | [Records](/sdk-reference/client/records) | `useQuery`, `useMutations`, `RecordProvider`, `RecordScope`, `useUsers`, `useUserLookup` | | [Messaging](/sdk-reference/client/messaging) | `useChannels`, `useMessages`, `useReactions`, `useChannelMembers`, `useReadReceipts` | | [Real-time](/sdk-reference/client/realtime) | `usePresence`, `usePresenceRoom`, `useYjsText`, `useYjsField`, `useCanvas`, `useVoiceAgent`, `useCronMonitor`, `useJobs` | | [Files](/sdk-reference/client/files) | `useR2Files`, `formatFileSize`, `isImageFile` | | [Integrations](/sdk-reference/client/integrations) | `integration.post`, OAuth helpers, platform-context exports | | [Payments](/sdk-reference/client/payments) | `useSubscription`, `useCheckout`, `PricingTable`, server helpers | | [Theming](/sdk-reference/client/theming) | `DeepSpaceThemeProvider`, `applyDeepSpaceTheme`, `getUserColor` | The client entry also exports the [environment helpers](#environment-helpers) and the [wire protocol layer](#wire-protocol-layer) documented at the bottom of this page. ## Schemas - `deepspace/schema` Runtime-neutral collection declarations - the `CollectionSchema` / `ColumnDefinition` / `ColumnInterpretation` / `RolePermissions` / `PermissionLevel` types plus the users, public messaging, and AI chat schemas. See the [schemas reference](/sdk-reference/worker/schemas) for every shape - the surface is identical, only the import home differs. ```ts // src/schemas/items-schema.ts — imported by both the worker and the browser import type { CollectionSchema } from 'deepspace/schema' ``` ## Worker - `deepspace/worker` Everything you import inside your Cloudflare Worker - DO base classes, schemas, auth verification, AI helpers. | Section | Covers | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | | [Rooms](/sdk-reference/worker/rooms) | `RecordRoom`, `YjsRoom`, `CanvasRoom`, `PresenceRoom`, `CronRoom`, `JobRoom`, `enqueueJob`, `DOManifest` | | [Schemas](/sdk-reference/worker/schemas) | `CollectionSchema`, RBAC types, drop-in collections, role constants | | [Server actions](/sdk-reference/worker/server-actions) | `ActionHandler`, `ActionContext`, `ActionTools`, `ActionResult` | | [AI](/sdk-reference/worker/ai) | `createDeepSpaceAI`, context compaction, chat history, built-in tools | | [Cron](/sdk-reference/worker/cron) | `CronTask`, `CronExecution`, `buildCronContext` | | [Auth](/sdk-reference/worker/auth) | `verifyJwt`, `createDeepSpaceAuth`, HMAC primitives | | [Bindings](/sdk-reference/worker/bindings) | `runMigrations`, `meterAi`, `meterVectorize`, `meterUsage`, manifest types | | [Proxy helpers](/sdk-reference/worker/proxy-helpers) | `apiWorkerFetch`, `platformWorkerFetch`, `authWorkerFetch` | ## Server - `deepspace/server` Platform-backed helpers for worker-side code that talks to the DeepSpace platform rather than your own DO: * `captureScreenshot` - shared Browser Rendering capture. See [bindings reference](/sdk-reference/worker/bindings#shared-browser-rendering-capturescreenshot). * `requireSubscription`, `getSubscription`, `cancelSubscription`, `refundInvoice` and their error classes - server-side payment gates and operations. See [payments reference](/sdk-reference/client/payments). ## Testing - `deepspace/testing` Multi-user Playwright fixture and account helpers. * [Testing reference](/sdk-reference/testing) - `test`, `expect`, `users(N)`, `MultiplayerUser`, account helpers. ## Environment helpers Exported from `deepspace` (and usable in shared code). The SDK detects which environment the code is running in - build-time `__DEEPSPACE_ENV__` define, then runtime `window.__DEEPSPACE_ENV__`, then server-side `process.env.DEEPSPACE_ENV`, then hostname - and derives the platform service URLs from it. | Export | Signature | Returns | | ----------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `detectEnvironment` | `() => Environment` | `'dev' \| 'staging' \| 'prod'` (cached after first call) | | `getEnvironmentConfig` | `() => EnvironmentConfig` | `{ name, apiUrl, platformWorkerUrl, authUrl, authSignInUrl, authSignUpUrl, mainAppUrl, dashboardUrl }` | | `getApiUrl` | `() => string` | Platform API worker URL for the current environment | | `getPlatformWorkerUrl` | `() => string` | Platform worker URL for the current environment | | `getAuthUrl` | `() => string` | Auth worker URL for the current environment | | `isLocalDev` | `() => boolean` | `detectEnvironment() === 'dev'` | | `isProduction` | `() => boolean` | `detectEnvironment() === 'prod'` | | `resetEnvironmentCache` | `() => void` | Clears the cached detection (useful in tests) | | `ENV` | object | Getter-based convenience: `ENV.current`, `ENV.config`, `ENV.apiUrl`, `ENV.platformWorkerUrl`, `ENV.authUrl`, `ENV.isLocal`, `ENV.isProd` | ```ts import { getApiUrl, isLocalDev } from 'deepspace' const res = await fetch(`${getApiUrl()}/api/health`) if (isLocalDev()) console.debug('api health', res.status) ``` ## Wire protocol layer For apps that build their own WebSocket client against a DeepSpace DO instead of using the built-in hooks. Exported from both `deepspace` and `deepspace/worker`, so a custom hook and a custom room speak the same typed vocabulary. | Export | What it is | | ------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `MSG` | Constants object of every JSON message type string (e.g. `MSG.PUT` is `'core.put'`) | | `ClientMessage` | Discriminated union of every client → server message | | `ServerMessage` | Discriminated union of every server → client message | | `clientBuild` | Typed builder factories for client → server messages (`clientBuild.put(...)`, `clientBuild.subscribe(...)`, ...) | | `dispatch(raw, handlers)` | Parses a raw frame (or accepts a parsed object) and routes it by `type`; returns `true` if a handler ran | | `encode(message)` | Serializes a built message for `ws.send` | ```ts import { MSG, clientBuild, dispatch, encode, type ServerMessage } from 'deepspace' ws.send(encode(clientBuild.put('notes', noteId, { title: 'Hello' }))) ws.onmessage = (event) => { dispatch<ServerMessage>(event.data, { [MSG.RECORD_CHANGE]: (payload) => applyChange(payload), [MSG.ERROR]: (payload) => console.error(payload.error), }) } ``` Most apps should use the [records hooks](/sdk-reference/client/records) instead - this layer exists for custom clients and custom rooms. ## TypeScript signatures This reference shows the most common signatures and shapes. For exact type definitions - including generic constraints, optional fields, and discriminated unions - read the bundled `.d.ts` files: | Module | Location | | ------------------------------- | ------------------------------------------------------ | | `deepspace` | `node_modules/deepspace/dist/index.d.ts` | | `deepspace/schema` | `node_modules/deepspace/dist/schema.d.ts` | | `deepspace/worker` | `node_modules/deepspace/dist/worker.d.ts` | | `deepspace/server` | `node_modules/deepspace/dist/server.d.ts` | | `deepspace/testing` | `node_modules/deepspace/dist/testing.d.ts` | | `deepspace/documentation` | `node_modules/deepspace/dist/documentation.d.ts` | | `deepspace/documentation/react` | `node_modules/deepspace/dist/documentation-react.d.ts` | `deepspace/documentation` and `deepspace/documentation/react` back the [documentation feature](/guides/documentation); apps rarely import them directly. If a hook or type isn't documented in this reference, it probably exists in the `.d.ts`. Read the declaration before guessing. Source: /sdk-reference/overview.md --- # Client auth reference Hooks, providers, and components for authentication in React. The auth surface is exported from `deepspace`. The Better Auth client is wrapped in a React provider, with hooks and components for everything you'd build by hand. ```ts import { DeepSpaceAuthProvider, AuthGate, AuthOverlay, SignedIn, SignedOut, GuestBanner, useAuth, useAuthUser, useUser, useDisplayName, useAuthStatus, useAuthProfileReady, signIn, signOut, getAuthToken, clearAuthToken, authClient, useSession, } from 'deepspace' ``` ## Providers ### `DeepSpaceAuthProvider` Wraps the tree and initializes the Better Auth client. Required as an ancestor of every auth hook and component. ```tsx <DeepSpaceAuthProvider> <App /> </DeepSpaceAuthProvider> ``` The scaffolded `_app.tsx` already mounts this provider at the root. You don't normally render it yourself. ## Hooks ### `useAuth(): AuthState` Primary auth-state hook. Session-based; updates immediately on sign-in / sign-out. | Field | Type | Description | | ------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `isLoaded` | `boolean` | `true` once the first Better Auth session check resolves, and **stays true** for the page's lifetime - does not flap back to `false` on background refetches or tab refocus. Safe to gate `RecordProvider` / data-layer mounts on without remount churn. | | `isSignedIn` | `boolean` | The canonical signed-in check. | | `userId` | `string \| null` | JWT subject; null when signed out. | | `sessionId` | `string \| null` | Better Auth session ID. | ```tsx const { isLoaded, isSignedIn, userId } = useAuth() if (!isLoaded) return <Skeleton /> if (!isSignedIn) return <SignInPrompt /> ``` ### `useAuthUser(): { isLoaded, isSignedIn, user }` Returns the auth-layer user from Better Auth's session - `{ id, fullName, firstName, primaryEmailAddress, ... } | null`. Use when you need fields from the OAuth provider's profile. Different from `useUser()`, which merges in storage-layer fields like role and karma. ### `useUser(): { user, isLoading, refetch }` Returns the storage-layer user, merged with the room-specific role from the app's users collection. Karma and credits are loaded from the API as nested objects when available. ```ts type UserKarma = { total: number breakdown: { publishing: number; content: number; comment: number; curation: number } rank: number monthlyKarma: number monthlyRank: number } type UserCredits = { total: number subscription: number bonus: number purchased: number } type StorageUser = { id: string name: string email: string /** Free-form role string from the user-roles collection; defaults to `'viewer'`. */ role: string imageUrl?: string isAdmin?: boolean publicUsername?: string | null subscriptionTier?: string | null subscriptionStatus?: string | null karma?: UserKarma | null credits?: UserCredits | null } ``` ```tsx const { user, isLoading } = useUser() if (isLoading) return null if (user?.role !== 'admin') return <p>Admins only</p> // Karma and credits are objects, not numbers: const totalKarma = user?.karma?.total ?? 0 const availableCredits = user?.credits?.total ?? 0 ``` `useUser` reads the merged profile + room-role from the record store, so it must be rendered inside a `RecordProvider` ancestor (not just `DeepSpaceAuthProvider`). Outside a `RecordProvider`, `user` will be `null`. Destructure as `const { user } = useUser()` and read fields off `user`. **Don't** write `const { id } = useUser()` - `id` is not at the top level of the return. ### `useDisplayName(): string | null` Resolves the best available display name (full name → first name → email username). Returns `null` while loading or signed out. ### `useAuthStatus(options?)` Auth-only readiness as a single load state. Wraps `useAuth()` - **safe outside `RecordProvider`** (app shells, top-level layouts), because it never touches the record store. ```ts function useAuthStatus(options?: { requireSignedIn?: boolean }): { // everything useAuth() returns: isLoaded: boolean isSignedIn: boolean userId: string | null sessionId: string | null // plus: status: 'loading' | 'ready' | 'empty' | 'error' | 'signedOut' isReady: boolean // status === 'ready' } ``` `status` is `'loading'` until the first session check resolves, `'signedOut'` when `requireSignedIn: true` and the visitor isn't signed in, and `'ready'` otherwise (the `'empty'` / `'error'` states belong to the wider load-state union and are not produced by this hook). ```tsx function AppShell({ children }: { children: ReactNode }) { const { isLoaded } = useAuthStatus() if (!isLoaded) return <div aria-busy="true" className="fixed inset-0" /> return <>{children}</> } ``` ### `useAuthProfileReady(options?)` Auth **plus profile** readiness, for profile-backed UI - user menus, role-gated nav, account names, admin controls. Combines `useAuth()` with `useUser()`, so it **requires a `RecordProvider` ancestor**. ```ts function useAuthProfileReady(options?: { requireSignedIn?: boolean requireUser?: boolean }): { // everything useAuth() returns, plus: user: StorageUser | null userLoading: boolean refetchUser: () => Promise<void> status: 'loading' | 'ready' | 'empty' | 'error' | 'signedOut' isReady: boolean } ``` With `requireUser: true`, `status` stays `'loading'` while `isSignedIn && userLoading` - render a skeleton there instead of flashing signed-out UI at a signed-in user whose profile hasn't arrived yet: ```tsx function AccountMenu() { const { isSignedIn, user, status } = useAuthProfileReady({ requireUser: true }) if (status === 'loading') return <MenuSkeleton /> if (!isSignedIn) return <SignInButton /> return <Menu name={user?.name} isAdmin={user?.role === 'admin'} /> } ``` Rule of thumb: `useAuth().isSignedIn` (or `useAuthStatus()`) for auth checks; `useAuthProfileReady({ requireUser: true })` wherever the UI reads profile fields. ### `useSession()` and `authClient` Re-exports from Better Auth for advanced flows (custom OAuth providers, magic links, etc.). ## Components ### `<AuthOverlay providers={...} />` Modal sign-in UI. Render without an `onClose` prop and gate on `!isSignedIn`; auto-hides when signed in. | Prop | Type | Description | | ----------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `providers` | `Array<'github' \| 'google'>` | Which OAuth buttons to show. Defaults to `['github', 'google']`. Email/password sign-in is always available below the OAuth options. | | `onClose` | `() => void` | If provided, renders a close button. Omit for non-dismissible. | ```tsx <AuthOverlay providers={['google', 'github']} /> ``` ### `<AuthGate fallback={...} redirectOnSignOut={...} />` Renders `children` when signed in; renders `fallback` (default: `<AuthOverlay />`) otherwise. | Prop | Type | Description | | ------------------- | ----------- | ---------------------------------------------------------------------------- | | `fallback` | `ReactNode` | UI shown to signed-out users. Defaults to non-dismissible `<AuthOverlay />`. | | `redirectOnSignOut` | `string` | Where the user lands on sign-out. Default `'/'`. Triggers a full reload. | ```tsx <AuthGate fallback={<TeaserPage />}> <ProtectedContent /> </AuthGate> ``` ### `<SignedIn>` and `<SignedOut>` Conditional rendering helpers: ```tsx <SignedIn><UserMenu /></SignedIn> <SignedOut><SignInButton /></SignedOut> ``` ### `<GuestBanner />` A small inline banner prompting sign-in for anonymous visitors. ## Functions ### `signIn` / `signOut` Re-exports from Better Auth. ```ts import { signOut } from 'deepspace' await signOut() ``` ### `getAuthToken(): Promise<string | null>` Returns the current JWT, refreshing it from the auth worker if necessary. Attach to outbound `fetch` calls: ```ts const r = await fetch('/api/premium', { headers: { Authorization: `Bearer ${await getAuthToken()}` }, }) ``` Under the hood it does `POST /api/auth/token` **same-origin** with `credentials: 'include'` - the app's worker (or the Vite proxy in dev) routes that to the auth worker, which trades the session cookie for a short-lived ES256 JWT. There is no cross-origin call and no client secret. The token is cached in module scope and refreshed \~30 s before its `exp`, so calling `getAuthToken()` on every request is cheap. Any non-OK response, or a body without a `token`, resolves to `null` rather than throwing - a signed-out browser is the ordinary case, so check for `null` instead of catching. Anything outside the browser (SSR, Node) also gets `null`. ### `clearAuthToken(): void` Clears the cached JWT. Forces the next `getAuthToken()` call to fetch a fresh one. Useful in tests or after explicit session changes. ## Patterns ### Auth-state checks ```tsx const { isLoaded, isSignedIn } = useAuth() if (!isLoaded) return <Skeleton /> return isSignedIn ? <App /> : <Landing /> ``` ### Profile access ```tsx const { user, isLoading } = useUser() if (isLoading) return null return user ? <Hi name={user.name} /> : <SignInPrompt /> ``` ### Sign-out button ```tsx import { signOut } from 'deepspace' <button onClick={() => signOut()}>Sign out</button> ``` ## See also * [Authentication guide](/guides/authentication) - public, gated, and mixed configurations * [Worker auth reference](/sdk-reference/worker/auth) - `verifyJwt` for custom routes Source: /sdk-reference/client/auth.md --- # Records reference Providers, hooks, and the data layer for reading and writing records. The records API is the primary surface for working with [collections](/concepts/data-model). Every hook and provider on this page is imported from `deepspace`. ```ts import { RecordProvider, RecordScope, ScopeRegistryProvider, useQuery, useMutations, useUsers, useUserLookup, useRecordContext, RecordRoomNotReadyError, type WriteError, } from 'deepspace' ``` For schemas and column types, see the [worker schemas reference](/sdk-reference/worker/schemas). For RBAC rules, see [permissions](/concepts/permissions). ## Providers ### `<RecordProvider>` Initializes the WebSocket and in-memory record store. Required ancestor of every records hook. | Prop | Type | Description | | ---------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `roomId` | `string` *(optional)* | Scope ID for the default room (usually `app:<APP_ID>` - the scaffold's `SCOPE_ID`, keyed to the [immutable app id, not the name](/concepts/data-model#scopes)). Omit for multi-scope mode and use `<RecordScope>` to mount scopes instead. | | `schemas` | `CollectionSchema[]` *(optional)* | All collections this provider tree may query. | | `wsUrl` | `string` *(optional)* | Override the WebSocket URL. Defaults to current origin. | | `fetchUser` | `() => Promise<UserProfile \| null>` *(optional)* | Custom user-profile fetcher. Defaults to using the Better Auth session. | | `allowAnonymous` | `boolean` *(optional)* | Connect without a JWT (default `false`). Required for public pages. See [authentication](/guides/authentication). | | `getAuthToken` | `() => Promise<string \| null>` *(optional)* | Custom token fetcher. Defaults to the SDK's. | | `onWriteError` | `(error: WriteError) => void` *(optional)* | Called when the server rejects a fire-and-forget write, or when a write is attempted before the room is ready. See [write errors](#write-errors-onwriteerror) below. | ```tsx <RecordProvider allowAnonymous> <App /> </RecordProvider> ``` #### Write errors (`onWriteError`) Fire-and-forget mutations (`create` / `put` / `remove`) resolve before the server answers, so a denied or invalid write surfaces through `onWriteError`. There is no optimistic local insert: subscribed query state changes only when the accepted broadcast returns. If you don't handle `onWriteError`, the SDK's default handler logs a deduplicated `console.error` telling you to wire real UI. ```ts interface WriteError { /** RBAC denial, data validation/other rejection, or a room that is not ready. */ kind: 'permission' | 'validation' | 'not_ready' /** Short human-readable summary, safe to show end users. */ title: string /** Longer human-readable explanation; may be empty. */ detail: string } ``` The scaffold wires it to toasts - permission denials as warnings, everything else as errors. Keep this wiring when customizing the layout, and retrofit it into apps scaffolded before the prop existed: ```tsx const { error, warning } = useToast() // scaffold's local toast hook <RecordProvider allowAnonymous onWriteError={(e) => e.kind === 'permission' ? warning(e.title, e.detail) : error(e.title, e.detail) } > <App /> </RecordProvider> ``` When the next step depends on the write being accepted, prefer the [confirmed mutation variants](#usemutations-t-collection) - they reject instead of routing through `onWriteError`. ### `<RecordScope>` Mounts a specific [scope](/concepts/architecture#scopes) (Durable Object instance). Nest for additional scopes. | Prop | Type | Description | | -------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `roomId` | `string` | App-owned scope ID, such as `app:app_01HZXYABCDEFGHJKMNPQRSTVWX` or `chat:thread_123`. | | `schemas` | `CollectionSchema[]` | Collections in this scope. | | `sharedScopes` | `Array<{ roomId, schemas }>` | Additional app-owned scopes to mount alongside the primary. | | `wsUrl` | `string` *(optional)* | Override WebSocket URL. | | `wsPathPrefix` | `string` *(optional)* | Override path prefix (default `/ws`). | | `isolated` | `boolean` | If true, don't register this scope's collections in the shared scope registry - prevents name collisions with other mounted scopes. | ### `<ScopeRegistryProvider>` Required once near the root if your app mounts multiple scopes via `sharedScopes`. Coordinates collection routing between those app-owned rooms. ## `useQuery<T>(collection, options?)` Subscribes to a collection. Returns a reactive array of [envelopes](/concepts/data-model#collections-and-records). ```ts function useQuery<T>( collection: string, options?: { where?: Partial<T> orderBy?: string orderDir?: 'asc' | 'desc' limit?: number }, ): { records: Envelope<T>[] status: 'loading' | 'ready' | 'error' error?: string } ``` Basic Subscribe to every record in a collection. The hook re-renders whenever any user mutates a record visible to the caller's [permissions](/concepts/permissions). ```tsx type Note = { title: string; body: string } function Notes() { const { records, status } = useQuery<Note>('notes') if (status === 'loading') return <Skeleton /> return records.map((r) => <li key={r.recordId}>{r.data.title}</li>) } ``` Filter `where` filters by exact field match. Filtering happens server-side before broadcast, so unauthorized records never leave the worker. ```tsx const { records } = useQuery<Note>('notes', { where: { pinned: true }, }) ``` Sort & limit `orderBy` accepts any field name including `createdAt` and `updatedAt`. `limit` caps the records sent over the wire. ```tsx const { records } = useQuery<Note>('notes', { orderBy: 'updatedAt', orderDir: 'desc', limit: 50, }) ``` Status Gate your UI on `status` rather than `records.length`. An empty array can mean either "loading" or "loaded with no rows". ```tsx const { records, status, error } = useQuery<Note>('notes') if (status === 'loading') return <SkeletonList /> if (status === 'error') return <ErrorBanner message={error} /> if (records.length === 0) return <EmptyState /> return <NoteList notes={records} /> ``` Envelope shape: ```ts type Envelope<T> = { recordId: string data: T createdBy: string createdAt: string updatedAt: string } ``` **User fields live under `.data`.** `r.title` returns `undefined` - always reach for `r.data.title`. TypeScript catches this if you pass a row type to `useQuery<T>`. ## `useMutations<T>(collection)` Returns fire-and-forget and confirmed mutation functions for the given collection. The local query store updates when the server broadcasts an accepted change. ```ts function useMutations<T>(collection: string): { /** True once the collection's RecordRoom can accept writes. */ ready: boolean create: (data: T) => Promise<string> put: (id: string, patch: Partial<T>) => Promise<void> remove: (id: string) => Promise<void> createConfirmed: (data: T) => Promise<string> putConfirmed: (id: string, patch: Partial<T>) => Promise<void> removeConfirmed: (id: string) => Promise<void> } ``` ### The `ready` gate Every method throws `RecordRoomNotReadyError` (a `Error` subclass with `code: 'not_ready'`) when called before the collection's RecordRoom connection is ready - during initial connect and after a disconnect. Disable write controls until `ready` so users can't trigger the throw: ```tsx const { ready, create } = useMutations<Note>('notes') <button disabled={!ready} onClick={() => create({ title: 'Untitled', body: '', pinned: false })}> New note </button> ``` If you do call a mutation from a code path that can run early, catch the error and check `err.code === 'not_ready'` to distinguish it from a server rejection. Create `create` takes the full row shape and returns the new `recordId`. The ID is generated on the client (timestamp + random suffix) before the write is sent, so the promise resolves with the ID immediately while the server processes the mutation in the background. ```tsx const { create } = useMutations<Note>('notes') const id = await create({ title: 'Untitled', body: '', pinned: false, }) ``` If you need to confirm the row was actually persisted (e.g., before navigating away), use `createConfirmed` instead - it awaits server acknowledgment: ```tsx const id = await createConfirmed({ title: 'New', body: '', pinned: false }) navigate(`/notes/${id}`) ``` Update `put` is **merge semantics** - the server applies `{ ...existing, ...patch }`. Send only the fields you're changing. ```tsx const { put } = useMutations<Note>('notes') await put(noteId, { pinned: true }) // only updates pinned await put(noteId, { title: 'New title' }) // only updates title ``` Do not spread the existing record: ```tsx // ❌ Wasteful - sends every field await put(noteId, { ...note.data, pinned: true }) // ✅ Send only what changed await put(noteId, { pinned: true }) ``` Delete `remove` is a hard delete. The record is dropped from the DO's SQLite store and broadcast as `record_removed` to every connected client. ```tsx const { remove } = useMutations<Note>('notes') await remove(noteId) ``` There is no soft-delete primitive at the records layer. For chat messages, use [`useMessages().softDelete`](/sdk-reference/client/messaging#usemessages-channelid-options) which sets a tombstone flag instead. Confirmed variants `createConfirmed` / `putConfirmed` / `removeConfirmed` resolve only after the DO has acknowledged the write. Use when the next step depends on server persistence - typically before navigation. ```tsx const { createConfirmed } = useMutations<Note>('notes') const id = await createConfirmed({ title: 'New' }) navigate(`/notes/${id}`) // safe - server has persisted ``` Plain `create` resolves with the client-generated ID before the server answers. Subscribed query state changes only after the accepted broadcast returns. If you need to navigate immediately, use the returned ID rather than reading a stale render closure. | Method | Semantics | Returns | | ----------------- | ---------------------- | ----------------------------------------------- | | `create` | Fire-and-forget | `Promise<string>` (client-generated `recordId`) | | `put` | Fire-and-forget, merge | `Promise<void>` | | `remove` | Fire-and-forget | `Promise<void>` | | `createConfirmed` | Waits for DO ack | `Promise<string>` | | `putConfirmed` | Waits for DO ack | `Promise<void>` | | `removeConfirmed` | Waits for DO ack | `Promise<void>` | ## `useUsers()` Returns the room's user directory with role-management helpers. ```ts type RoomUser = { id: string /** Present only for admin callers. */ email?: string name: string imageUrl?: string role: string /** Present only for admin callers. */ createdAt?: string /** Present only for admin callers. */ lastSeenAt?: string } function useUsers(): { users: RoomUser[] usersLoaded: boolean setRole: (userId: string, role: string) => void refresh: () => void } ``` `setRole` accepts a free-form role string (e.g. `'admin'`, `'intern'`, or any value your schema understands) and dispatches the change without waiting for an ack. The Durable Object enforces who is allowed to call it. See [permissions](/concepts/permissions#roles) for how roles map onto collection RBAC rules. ### The directory privacy contract The directory is filtered server-side, in two steps: 1. **Row policy.** Anonymous sockets receive no directory at all. For authenticated callers, rows first pass the app's `users` collection read policy for the caller's role - the fresh scaffold ships `member.read: 'own'`, so a regular member's directory contains only their own row unless the app explicitly broadens the policy. 2. **Field projection.** Rows that pass are then projected to public identity for non-admin callers: `{ id, name, imageUrl?, role }`. Admins receive the full fields (`email`, `createdAt`, `lastSeenAt`, plus any custom columns) - which is also why [`useUserLookup().getEmail`](#useuserlookup) only resolves emails for admin callers. **Plain `useQuery('users')` bypasses the projection.** It is an ordinary collection subscription: it applies the row read policy but returns *every* field the schema allows, emails included. Never set `read: true` on the `users` collection unless every current and future users column is intentionally visible to every member. See [permissions](/concepts/permissions). ## `useUserLookup()` O(1) wrapper around `useUsers()` for resolving `userId`s to display fields. ```ts type UserInfo = { id: string /** Available to admins; ordinary members receive public identity only. */ email?: string name: string imageUrl?: string role: string } function useUserLookup(): { users: RoomUser[] usersLoaded: boolean userMap: Map<string, UserInfo> getUser: (userId: string) => UserInfo | null getEmail: (userId: string) => string | null getName: (userId: string) => string | null } ``` ```tsx const { getName } = useUserLookup() <p>By {getName(message.authorId) ?? 'unknown'}</p> ``` There is no `getRole` or `getImageUrl` - read those off `getUser(id)?.role` or `getUser(id)?.imageUrl`. `getEmail` resolves an email only when the caller is an admin - non-admin callers receive the [public-identity projection](#the-directory-privacy-contract), which carries no email, so `getEmail` returns `null` for them. Don't build member-facing UI that depends on it. ## `useRecordContext()` Low-level access to the record-store context (WebSocket send/receive primitives, ready state, user profile, etc.). Useful for building custom hooks or imperative reads outside React's render cycle. Most apps never need this. ## See also * [Data storage guide](/guides/data-storage) - schemas, CRUD, and patterns. * [Data model](/concepts/data-model) - collections, envelopes, scopes. * [Permissions](/concepts/permissions) - RBAC rules and the `'own'` / `'shared'` / `'published'` shortcuts. * [Real-time sync](/concepts/realtime-sync) - mutation pipeline and consistency guarantees. * [Worker schemas reference](/sdk-reference/worker/schemas) - the `CollectionSchema` type and drop-in collections. Source: /sdk-reference/client/records.md --- # Messaging reference Public channels, messages, reactions, members, and read receipts. The messaging API is an app-owned, public-chat layer. Add the five [messaging schemas](/sdk-reference/worker/schemas#drop-in-schemas-messaging) to your `RecordRoom`, then import the hooks from `deepspace`. ```ts import { useChannels, useMessages, useReactions, useChannelMembers, useReadReceipts, } from 'deepspace' ``` Each hook returns `status: 'loading' | 'ready' | 'error'` and `error?: string` with its records. The SDK does not include a global conversation or directory service. The bundled schemas support public channels only. For private messages, define app-specific schemas and enforce participant access in your worker. A client-side membership check is not an authorization boundary. ## `useChannels()` ```ts function useChannels(): { channels: RecordData<Channel>[] status: 'loading' | 'ready' | 'error' error?: string create: (input: { name: string; description?: string }) => Promise<string> update: (channelId: string, patch: Partial<Pick<Channel, 'name' | 'description'>>) => void archive: (channelId: string) => void remove: (channelId: string) => Promise<void> } ``` `create` always writes `type: 'public'`. `update`, `archive`, and `remove` are fire-and-forget. The `remove` promise resolves after the client sends the request, before the room answers; handle a room refusal with `RecordProvider.onWriteError`. ```tsx const { create, archive } = useChannels() const channelId = await create({ name: 'general', description: 'Announcements' }) archive(channelId) ``` ## `useMessages(channelId, options?)` ```ts function useMessages( channelId: string | undefined, options?: { parentMessageId?: string }, ): { messages: RecordData<Message>[] status: 'loading' | 'ready' | 'error' error?: string send: (content: string, parentMessageId?: string) => Promise<string> | undefined edit: (messageId: string, newContent: string) => void softDelete: (messageId: string) => void remove: (messageId: string) => void } ``` `send` returns the new ID, or `undefined` without both a channel and signed-in user. `edit`, `softDelete`, and `remove` are fire-and-forget. Use `softDelete` for user-facing deletion so reply relationships remain intact. Pass `options.parentMessageId` to query one reply thread. ```tsx const { messages, send } = useMessages(channelId) await send('Hello') await send('A reply', parentMessageId) ``` ## `useReactions(channelId)` ```ts type GroupedReaction = { emoji: string count: number currentUserReacted: boolean userIds: string[] } function useReactions(channelId: string | undefined): { reactions: RecordData<Reaction>[] status: 'loading' | 'ready' | 'error' error?: string getReactionsForMessage: (messageId: string) => GroupedReaction[] toggle: (messageId: string, emoji: string) => void } ``` `toggle` is fire-and-forget. The schema's uniqueness constraint prevents duplicate `(messageId, emoji, userId)` rows. ## `useChannelMembers(channelId)` ```ts function useChannelMembers(channelId: string | undefined): { members: RecordData<ChannelMember>[] status: 'loading' | 'ready' | 'error' error?: string join: () => Promise<void> leave: () => Promise<void> isMember: boolean } ``` `join` and `leave` use confirmed mutations. They resolve after acceptance and reject on failure. Membership is an opt-in signal for public channels; it does not restrict who can read messages. ## `useReadReceipts()` ```ts function useReadReceipts(): { receipts: RecordData<ReadReceipt>[] status: 'loading' | 'ready' | 'error' error?: string markAsRead: (channelId: string) => void getUnreadCount: (channelId: string, messages: RecordData<Message>[]) => number } ``` `markAsRead` is fire-and-forget and stores the current timestamp. `getUnreadCount` compares message creation times with that timestamp. ## Record types ```ts interface Channel { name: string description?: string type: 'public' createdBy: string archived: boolean } interface Message { channelId: string content: string authorId: string parentMessageId?: string edited: boolean editedAt?: string deleted?: boolean } interface Reaction { messageId: string channelId: string emoji: string userId: string } interface ChannelMember { channelId: string userId: string joinedAt: string } interface ReadReceipt { channelId: string userId: string lastReadAt: string } ``` Each value is wrapped in the standard record envelope (`recordId`, `data`, `createdBy`, `createdAt`, `updatedAt`). ## See also * [Messaging guide](/guides/messaging) — worked public-chat UI. * [Permissions](/concepts/permissions) — app-specific participant access. * [Schemas reference](/sdk-reference/worker/schemas) — collection definitions and RBAC. Source: /sdk-reference/client/messaging.md --- # Real-time reference Presence, Yjs, canvas, voice agents, cron, jobs, and cursor color helpers. The real-time hooks beyond records and messaging - presence, collaborative editing, canvas, voice agents, cron, jobs, and helpers. ```ts import { // Yjs useYjsText, useYjsField, useYjsRoom, // Canvas useCanvas, // Presence usePresence, usePresenceRoom, // Cron monitor useCronMonitor, // Background jobs useJobs, // User colors DEFAULT_USER_COLORS, getUserColor, // Low-level sync primitives createEncoder, createDecoder, encodeSyncStep1, encodeSyncStep2, encodeUpdate, handleSyncMessage, Awareness, encodeAwarenessMessage, handleAwarenessMessage, } from 'deepspace' ``` ## Yjs hooks ### `useYjsText(collection, recordId, fieldName)` Collaborative plain text bound to a record field. ```ts function useYjsText( collection: string, recordId: string, fieldName: string, ): { text: string setText: (value: string) => void synced: boolean canWrite: boolean } ``` ### `useYjsField(collection, recordId, fieldName)` Lower-level Yjs binding. Returns the raw `Y.Doc` and `Awareness` so you can build any Yjs type (`Y.Map`, `Y.Array`, `Y.XmlFragment`, etc.) on top of it. Not generic - the hook doesn't model the field shape. ```ts function useYjsField( collection: string, recordId: string, fieldName: string, ): { doc: Y.Doc awareness: Awareness synced: boolean canWrite: boolean /** Increments on every local or remote Y.Doc update - useful as a render trigger. */ updateCount: number } ``` Build whichever Yjs type you need off `doc`: ```tsx const { doc, synced } = useYjsField('documents', docId, 'tasks') const list = useMemo(() => doc.getArray<Task>('tasks'), [doc]) ``` ### `useYjsRoom(docId, fieldName)` Standalone Yjs document not tied to a record. Useful for ephemeral collaboration sessions. Opens a direct WebSocket to a dedicated `YjsRoom` DO at `/ws/yjs/:docId`. ```ts function useYjsRoom(docId: string, fieldName: string): { doc: Y.Doc awareness: Awareness text: string setText: (value: string) => void synced: boolean canWrite: boolean } ``` `text` / `setText` are bound to the `Y.Text` at `fieldName`. Use `doc` directly if you need a different type. `awareness` is a [y-protocols `Awareness`](https://github.com/yjs/y-protocols) instance pre-wired to the same WebSocket. Calls to `awareness.setLocalState(...)` or `awareness.setLocalStateField('cursor' | 'selection' | 'user' | …, value)` fire an `MSG_AWARENESS` frame to peers; remote states arrive on the awareness `'change'` / `'update'` events and are visible via `awareness.getStates()`. Pass it to an editor binding (e.g. `@tiptap/extension-collaboration-cursor`) or wire your own cursor/selection UI - see the [collaborative editing guide](/guides/collaborative-editing#cursors-and-selections) for a worked example. The `/ws/yjs/:docId` route is token-required and docs-aware: 401 without a verified JWT, 403 without read access. With the docs feature installed, roles resolve from `documents.ownerId` / `editors` / `collaborators`; without it, any authenticated caller is treated as `member`. See the [security model](/concepts/architecture#security-model-websocket-identity) for the full path. For the raw protocol constants (`MSG_AWARENESS`, `encodeAwarenessMessage`, `handleAwarenessMessage`) used by this hook internally, see [Low-level sync primitives](#low-level-sync-primitives) below. ## `useCanvas(roomId)` Connects to a `CanvasRoom` DO. ```ts function useCanvas(roomId: string): { shapes: CanvasShapeClient[] /** All connected users' viewports (including self). */ viewports: ViewportClient[] connected: boolean /** RBAC gate. False until the server AUTH frame lands; stays false for viewers. */ canWrite: boolean addShape: (shape: Partial<CanvasShapeClient>) => void moveShape: (shapeId: string, x: number, y: number) => void resizeShape: (shapeId: string, width: number, height: number, x?: number, y?: number) => void updateShape: (shapeId: string, props: Record<string, unknown>) => void deleteShape: (shapeId: string) => void setViewport: (viewport: Omit<ViewportClient, 'userId'>) => void undo: () => void redo: () => void } ``` All mutation methods are fire-and-forget - they encode and send a typed message and return `void`. `viewports` is an array, not a `Map`. Use `canWrite` to disable shape/draw controls for viewers - shape mutations (`addShape`, `moveShape`, `resizeShape`, `updateShape`, `deleteShape`, `undo`, `redo`) **silently no-op when `canWrite` is `false`**. `setViewport` is exempt and stays open for viewers (viewport broadcasts are presence-like, not stateful writes). Each shape: ```ts type CanvasShapeClient = { id: string type: string // free-form: 'rect', 'circle', 'text', etc. x: number y: number width: number height: number rotation?: number props: Record<string, unknown> // app-specific payload createdBy: string createdAt: string updatedAt: string } type ViewportClient = { userId: string x: number y: number width: number height: number zoom: number } ``` ## Presence hooks ### `usePresence(options?)` Online/offline status derived from `lastSeenAt` heartbeats on the users collection. ```ts function usePresence(options?: { timeoutMs?: number }): { users: RoomUser[] isOnline: (userId: string) => boolean getLastSeen: (userId: string) => string | null } ``` The hook also sends a heartbeat every 60 seconds so the server refreshes the caller's `lastSeenAt`. Default `timeoutMs` is 5 minutes. ### `usePresenceRoom(scopeId)` High-frequency ephemeral state (cursors, typing, viewport). Connects to a dedicated `PresenceRoom` DO. ```ts function usePresenceRoom(scopeId: string): { peers: PresencePeerClient[] // excludes self connected: boolean updateState: (state: object) => void // merges } type PresencePeerClient = { userId: string userName: string userEmail: string userImageUrl?: string joinedAt: string state: Record<string, unknown> } ``` `scopeId` is any string. Common patterns: `canvas:${canvasId}`, `thread:${channelId}`, `doc:${docId}`. ## `useCronMonitor(roomId)` Admin/monitor stream for the `CronRoom` DO. Pass the scaffold's `SCOPE_ID` (`app:` + the immutable `APP_ID` from `src/constants.ts`) for the app's default cron room - scope ids key to the app id, never the mutable app name. ```ts function useCronMonitor(roomId: string): { tasks: CronTaskState[] history: CronHistoryEntry[] connected: boolean /** RBAC gate. False until the server AUTH frame lands; stays false for read-only viewers. */ canWrite: boolean lastError: string | null trigger: (taskName: string) => Promise<CronMutationResult> pause: (taskName: string) => Promise<CronMutationResult> resume: (taskName: string) => Promise<CronMutationResult> } type CronMutationResult = | { ok: true; taskName: string; requestId: string } | { ok: false reason: 'read_only' | 'not_connected' | 'unknown_task' | 'failed' error?: string } type CronTaskState = { name: string /** Required. Null when the task is configured via `schedule` instead. */ intervalMinutes: number | null /** Required. Null when the task is configured via `intervalMinutes` instead. */ schedule: string | null /** Required. Null when the task has no explicit timezone. */ timezone: string | null paused: boolean lastRunAt: string | null nextRunAt: string | null } type CronHistoryEntry = { taskName: string startedAt: string completedAt: string | null success: boolean durationMs: number error?: string } ``` `trigger`, `pause`, and `resume` return receipts. Await and inspect `ok` whenever subsequent UI depends on the mutation. Read-only and disconnected calls resolve locally with typed failure reasons; unknown tasks and execution failures come from the room. `lastError` exposes the most recent general error frame. **Members and admins can fire owner-billed tasks by default.** The Cron DO authorizes `trigger` / `pause` / `resume` off the role the WebSocket route resolves. The scaffolded `/ws/cron/:roomId` resolves each authenticated caller's **current app role** via `resolveAppRole`; anonymous connections have no role, and `CronRoom` enforces viewers and anonymous connections as read-only. So `canWrite` is `true` for members and admins, `false` for everyone else. For admin-only writes, customize the route's role resolver to return a writer role only for admins - and disable the controls client-side by `canWrite` so the buttons match server policy. See the [scheduled jobs guide](/guides/scheduled-jobs#monitor-and-trigger-from-the-ui-usecronmonitor). ## `useJobs(roomId)` Enqueue and monitor durable background jobs on the `JobRoom` DO. Pass the scaffold's `SCOPE_ID` (`app:` + the immutable `APP_ID` from `src/constants.ts`) for the per-app job room - scope ids key to the app id, never the mutable app name. The hook subscribes via WebSocket, so `jobs` re-renders on every state change - no polling - and auto-reconnects on socket drop. ```ts function useJobs(roomId: string): { jobs: JobView[] connected: boolean enqueue( type: string, payload?: unknown, opts?: { maxAttempts?: number }, ): Promise<string> // resolves with the jobId getJob(id: string): JobView | undefined cancel(id: string): void retry(id: string): void } type JobView = { id: string type: string status: 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled' payload?: unknown result?: unknown error?: string /** 0..1 - present while live. */ progress?: number progressMessage?: string attempts: number maxAttempts: number enqueuedAt: string startedAt?: string completedAt?: string enqueuedBy?: string } ``` `enqueue` resolves once the server acks (10 s timeout, also rejects on socket close). `cancel(id)` is best-effort - it always flips the DB row to `canceled`, and if the cancel reaches the isolate running the job, `ctx.signal` fires so any in-flight `fetch(...)` you wired with `signal: ctx.signal` aborts cleanly. `retry(id)` re-queues a failed or canceled job. The handler runs in your `AppJobRoom`'s `onJob(job, ctx)` - see [Background jobs](/guides/background-jobs) for the worker-side pattern and the `enqueueJob` helper for HTTP routes / cron / actions. **`enqueue` / `cancel` / `retry` require a verified member or admin write role by default.** `JobRoom`'s `authorizeWrite` defaults to member/admin, the scaffolded `AppJobRoom` resolves each caller's current app role (rejecting anonymous connections), and the DO re-checks the role on **every mutation**, not just at connect - a denied call rejects with a write-access error. Client-side gating is UX; `authorizeWrite` is the security boundary. Tighten it to admin-only for jobs that spend owner credits, and route deliberately public producers through an app-owned HTTP action that calls `enqueueJob` server-side. See [Background jobs](/guides/background-jobs#who-can-enqueue) for both patterns. ## `useVoiceAgent(options?)` A managed voice session against OpenAI's Realtime API over WebRTC - the SDK's voice surface. There is no LiveKit room hook; for multi-party audio/video rooms use the [`livekit/*` endpoints](/guides/livekit) with LiveKit's own client libraries. ```tsx const voice = useVoiceAgent({ instructions: 'You are a helpful cooking assistant.', onToolCall: async (name, args) => runTool(name, args), }) ``` Options: `instructions`, `voice`, `tools` (OpenAI tool definitions passed through to the session), `maxMinutes` (hard cap - the hook stops the call), and `onToolCall(name, args)` (called when the model invokes a tool; the return value is sent back). Returns `{ status, start(overrides?), stop(), isMuted, toggleMute, isAgentSpeaking, transcript, error, pc, dataChannel }` - `status` is `'idle' | 'connecting' | 'live' | 'ended' | 'error'`, `transcript` is an array of `{ role: 'user' | 'assistant', content }`, and `pc`/`dataChannel` are escape hatches to the underlying `RTCPeerConnection` for advanced use. ## User colors ```ts // 12-color palette of cursor/avatar tints const DEFAULT_USER_COLORS: readonly string[] // Deterministic hash: same userId → same color function getUserColor(userId: string, palette?: string[]): string ``` Use for cursor dots in `usePresence` / `usePresenceRoom`, avatar fallbacks, and "who's typing" pills. ## Low-level sync primitives For building custom hooks against a DeepSpace Yjs DO (rare): ```ts createEncoder, createDecoder toUint8Array, writeVarUint, writeVarUint8Array, readVarUint, readVarUint8Array encodeSyncStep1, encodeSyncStep2, encodeUpdate handleSyncMessage Awareness, encodeAwarenessMessage, handleAwarenessMessage getMessageType MSG_SYNC, MSG_AWARENESS, MSG_SYNC_STEP1, MSG_SYNC_STEP2, MSG_SYNC_UPDATE ``` Most apps never use these directly. Use the higher-level hooks (`useYjsText`, etc.) unless you're building a custom binding. ## See also * [Collaborative editing guide](/guides/collaborative-editing) * [Presence and cursors guide](/guides/presence-and-cursors) * [Canvas guide](/guides/canvas) * [Scheduled tasks guide](/guides/scheduled-jobs) * [Background jobs guide](/guides/background-jobs) Source: /sdk-reference/client/realtime.md --- # Files reference `useR2Files` and file display helpers. The `useR2Files` hook handles uploads, listings, deletions, and signed URLs against the app's R2 bucket. All operations route through the platform's file gateway, so end users never touch raw R2 credentials. For patterns and worked examples, see the [file uploads guide](/guides/file-uploads). ```ts import { useR2Files, isImageFile, formatFileSize } from 'deepspace' import type { R2FileInfo, R2Scope } from 'deepspace' ``` ## `useR2Files(options?)` ```ts type R2UploadResult = { success: boolean key?: string url?: string name?: string error?: string } function useR2Files(options?: R2Scope): { upload: (file: File | Blob, name?: string) => Promise<R2UploadResult> uploadBase64: (base64Data: string, name: string, mimeType?: string) => Promise<R2UploadResult> deleteFile: (fileOrKey: R2FileInfo | string) => Promise<{ success: boolean; error?: string }> downloadFile: (fileOrKey: R2FileInfo | string, fileName?: string) => Promise<{ success: boolean; error?: string }> readFile: (fileOrKey: R2FileInfo | string) => Promise<Response> list: (prefix?: string) => Promise<R2FileInfo[]> getUrl: (fileOrKey: R2FileInfo | string) => string isUploading: boolean } ``` `options` is the `R2Scope` itself. Two scopes are valid - `'self'` (the default) and `'app'` - and the choice decides both where files live and who can read them. See [Scopes](#scopes). Every method that takes a file accepts either an `R2FileInfo` object from `list()` or a raw key string. Upload `upload` accepts a `File` (from `<input type="file">` or a drag-drop event) or a `Blob` and an optional display name. Returns `R2UploadResult` - check `success` and read the `key` field. ```tsx const { upload, isUploading } = useR2Files() async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0] if (!file) return const result = await upload(file, file.name) if (!result.success) return console.error(result.error) console.log('uploaded:', result.key) } ``` Upload (base64) Use when you have data as a Base64 string - for example, from `<canvas>` `toDataURL()`. `name` is required; `mimeType` is optional. ```tsx const { uploadBase64 } = useR2Files() const canvas = canvasRef.current! const dataUrl = canvas.toDataURL('image/png') const base64 = dataUrl.split(',')[1] const result = await uploadBase64(base64, 'drawing.png', 'image/png') ``` List `list()` is an async function - call it and store the result in component state rather than expecting a reactive array. Pass a sub-prefix to filter. ```tsx import { useState, useEffect } from 'react' const { list } = useR2Files() const [files, setFiles] = useState<R2FileInfo[]>([]) async function refresh() { setFiles(await list()) } useEffect(() => { refresh() }, []) ``` Delete Removes the file from R2 and broadcasts the change. Accepts either the full `R2FileInfo` from `list()` or a raw key string. There is no recycle bin - deletes are immediate and irreversible. ```tsx const { deleteFile } = useR2Files() await deleteFile(file) // R2FileInfo from list() await deleteFile('reports/q1.pdf') // or a raw key ``` Read / download `getUrl()` returns a plain URL (no auth attached - only usable for unauthenticated reads). `downloadFile()` triggers a browser-side blob download and returns `{ success, error? }`. `readFile()` returns the raw `Response` so you can call `.text()`, `.blob()`, `.arrayBuffer()`, `.json()`, etc. ```tsx const { getUrl, downloadFile, readFile } = useR2Files() <img src={getUrl(file)} alt="" /> await downloadFile(file) // triggers Save As… await downloadFile('reports/q1.pdf', 'q1.pdf') // explicit filename const response = await readFile(file) const text = await response.text() ``` `list()` is an async function - call it and store the result in component state rather than reading a reactive array. ## `R2FileInfo` ```ts type R2FileInfo = { key: string size: number uploaded: string url: string originalName?: string uploadedBy?: string } ``` There is no `mimeType` / `contentType` field on `R2FileInfo`. Capture the MIME type at upload time and store it in a sidecar [collection](/concepts/data-model) if you need it later. See [storing metadata](/guides/file-uploads#storing-metadata-mime-type-captions-tags). ## Scopes ```ts type R2Scope = { scope?: 'self' | 'app' } ``` Scope decides where files live and, crucially, **who can read them**. | Scope | Prefix | Reads | | ------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------- | | `'self'` (default) | `apps/<app>/users/<userId>/…` | Require the caller's auth token. Not usable from a plain `<img>` or an unauthenticated request. | | `'app'` | `apps/<app>/…` | **Public.** No auth header needed - the returned URL works directly as an `<img src>`. | ```tsx // Per-user files (default) - private to the signed-in user const { upload, downloadFile } = useR2Files() await upload(myFile, 'photo.png') // App-shared files - public reads, embeddable in pages const { upload } = useR2Files({ scope: 'app' }) const r = await upload(file, `avatars/${userId}.png`) // r.url is a plain, anon-readable URL ``` **`scope: 'app'` files are world-readable.** Anyone who knows or guesses the key can fetch them without signing in. Use it for avatars, logos, and other assets meant to be embedded; never for anything private. Uploads under `'app'` still require a signed-in user - it's the *reads* that are open. `getUrl()` attaches no auth token, so it works for `'app'`-scope files but not `'self'`-scope ones. For private files use `readFile` or `downloadFile`, which send the Authorization header. Both scopes are per-app: the platform derives the bucket prefix server-side, so a key can never address another app. For finer namespacing within a scope (per-room, per-project), encode it into the key. `useR2Files` is the **in-app** surface, driven by an end user's session. The owner-side equivalent, for publishing assets from your machine without a deploy, is [`deepspace app files`](/guides/file-uploads#large-files-and-media). Both reach the same app-scoped storage. ## Display helpers | Helper | Signature | | ------------------------------- | ------------------------------------ | | `isImageFile(mimeType: string)` | Returns true for `image/*` MIMEs | | `formatFileSize(bytes: number)` | Returns `'1.2 MB'`, `'456 KB'`, etc. | ## Local dev limitation R2 uploads require an `APP_IDENTITY_TOKEN` minted by the deploy worker. The CLI does not provision this token locally, so `upload()` round-trips return 401 from the platform file gateway. In local dev, assert that uploads are *dispatched*; the full flow works only against [deployed apps](/concepts/deployment). ## See also * [File uploads guide](/guides/file-uploads) - patterns and worked examples. * [Custom bindings](/guides/custom-bindings) - declare your own R2 bucket with custom permissions. Source: /sdk-reference/client/files.md --- # Integrations reference The `integration` client and OAuth helpers. The `integration` object fronts 215+ third-party API endpoints through the platform's signed proxy. For discovery, billing, and the full workflow see the [external APIs guide](/guides/external-apis); for the CLI catalog, see [`deepspace integrations`](/cli-reference/commands#integrations). ```ts import { integration } from 'deepspace' import type { IntegrationResponse, RequestOptions } from 'deepspace' ``` ## `integration` The integration client exposes four HTTP verbs. All return a typed envelope. ```ts const integration: { get<T>: (endpoint: string, params?: Record<string, string | number | boolean | null | undefined>, options?: RequestOptions) => Promise<IntegrationResponse<T>> post<T>: (endpoint: string, data?: unknown, options?: RequestOptions) => Promise<IntegrationResponse<T>> put<T>: (endpoint: string, data?: unknown, options?: RequestOptions) => Promise<IntegrationResponse<T>> delete<T>: (endpoint: string, data?: unknown, options?: RequestOptions) => Promise<IntegrationResponse<T>> } ``` `get` takes a query-parameter object as its middle argument; the others take an optional JSON body. `delete` also accepts a body - the api-worker dispatches `DELETE` with a JSON payload when one is provided. Endpoint names are two segments: `<integration>/<endpoint>` (e.g. `openai/chat-completion`). POST The most common verb. Use for actions, completions, lookups, and anything with a request body. ```ts const result = await integration.post('openai/chat-completion', { model: 'claude-sonnet-5', messages: [{ role: 'user', content: 'Hello' }], }) if (result.success) { console.log(result.data) } else { console.error(result.error, result.issues) } ``` GET For idempotent reads. Pass query parameters as the second argument - they're serialised onto the URL automatically (null/undefined values are skipped). ```ts const result = await integration.get('finnhub/market-news', { category: 'crypto', minId: 100, }) ``` PUT For idempotent updates against REST-style endpoints. ```ts const result = await integration.put('notion/update-page', { pageId: 'abc123', properties: { Status: 'Done' }, }) ``` DELETE For destructive operations. The platform proxy is signed and rate-limited per integration; OAuth scopes apply if the integration is OAuth-backed. ```ts const result = await integration.delete('notion/delete-block', { blockId: 'xyz789', }) ``` **Auth-gate any UI that calls `integration.post(...)` for `'developer'`-billed endpoints.** The api-worker accepts anonymous callers, so a public endpoint silently bills the owner for every visitor (or bot) hit. Wrap calling components in [`useAuth().isSignedIn`](/sdk-reference/client/auth#useauth-authstate). See the [external APIs guide](/guides/external-apis#billing-developer-vs-user) for billing routing. ## `IntegrationResponse<T>` ```ts type IntegrationResponse<T> = | { success: true; data: T } | { success: false; error: string; issues?: ValidationIssue[] } type ValidationIssue = { path?: string[] message: string code?: string } ``` `issues` appears when the api-worker's Zod validator rejects the body shape. Read it instead of guessing field names - or run [`deepspace integrations info <endpoint>`](/cli-reference/commands#integrations) to print the schema before you call. ## `RequestOptions` ```ts type RequestOptions = { headers?: Record<string, string> timeoutMs?: number // default 120000 (120s) signal?: AbortSignal // cancel the request from the caller } ``` ```ts const r = await integration.post('exa/search', body, { timeoutMs: 30_000, headers: { 'X-Custom': 'value' }, }) ``` `signal` aborts the in-flight request and resolves the envelope as `{ success: false, error: 'Request cancelled' }`. Pass the `AbortSignal` that [`useAsyncResource`](#useasyncresource) hands your fetcher so unmounts and dependency changes cancel cleanly. ## Async resource hooks Two general-purpose hooks turn any async fetch - most commonly an integration call - into render-ready UI state: loading, error with a local retry, empty, and success. Use them instead of hand-rolled `useEffect` fetch state, and keep failures in place: **a failed resource re-fires via `reload()`/`retry()`, never by reloading the page**. Usage patterns live in the [external APIs guide](/guides/external-apis#ui-states-for-integration-data). ```ts import { useAsyncResource, usePagedResource } from 'deepspace' ``` ### `useAsyncResource` One-shot fetch keyed on a dependency array - a lookup, a single completion, a status check. ```ts function useAsyncResource<T>( fetcher: (signal: AbortSignal) => Promise<T>, deps: readonly unknown[], options?: UseAsyncResourceOptions<T>, ): AsyncResourceState<T> & { reload: () => void } type AsyncResourceState<T> = { status: 'idle' | 'loading' | 'ready' | 'error' data: T | null error: string | null isRefreshing: boolean // a re-fetch is in flight while previous data stays visible isSlow: boolean // the in-flight request has exceeded slowAfterMs retryCount: number } type UseAsyncResourceOptions<T> = { enabled?: boolean // default true; false parks the hook at 'idle' initialData?: T | null keepPreviousData?: boolean // default true - previous data stays visible during re-fetch retry?: number // automatic retries after failure; default 0 retryDelayMs?: number // default 2000 slowAfterMs?: number // default 10000; 0 disables the isSlow signal } ``` The fetcher must **throw** on failure so the hook can capture the error - for integration envelopes, `if (!r.success) throw new Error(r.error)`. The fetch re-runs when `deps` change; `reload()` re-fires it manually. Forward the provided `AbortSignal` (e.g. as `RequestOptions.signal`) so unmounts and dependency changes cancel in-flight work. ### `usePagedResource` Bounded, append-on-demand pagination for feeds. It fetches page 1 automatically, appends on `loadMore()`, and clamps oversized pages so a feed stays bounded instead of pulling an entire upstream dataset. ```ts function usePagedResource<T>( fetchPage: (args: { page: number; pageSize: number; signal: AbortSignal }) => Promise<{ items: T[]; hasMore?: boolean }>, deps: readonly unknown[], options?: UsePagedResourceOptions<T>, ): PagedResourceState<T> & { loadMore: () => void // fetch the next page and append retry: () => void // re-fire the failed page refresh: () => void // restart from page 1 } type PagedResourceState<T> = { status: 'idle' | 'loading' | 'ready' | 'error' items: T[] error: string | null warning: string | null // set when an oversized page was clamped hasMore: boolean isLoadingInitial: boolean isLoadingMore: boolean isRefreshing: boolean } type UsePagedResourceOptions<T> = { enabled?: boolean initialItems?: T[] pageSize?: number // default 20 maxItemsPerPage?: number // default pageSize; larger API pages are clamped with a warning keepPreviousData?: boolean // default true autoRetryOnError?: boolean // default false - failures wait for retry() with backoff otherwise off retryDelayMs?: number // default 2000 (backoff base) maxRetryDelayMs?: number // default 30000 } ``` `hasMore` comes from the page result (`hasMore ?? items.length >= pageSize`). When a page fails after items have already loaded, `status` stays `'ready'` and `error` is set - render an inline retry next to the intact list rather than replacing it with an error screen. ## OAuth endpoints Google (`google/*`) is the OAuth-backed integration surface. Two REST endpoints manage per-user connections - call them via `fetch` with the session token. There is **no connect endpoint**: authorization URLs come only from the [`requiresOAuth` response](#requiresoauth-response), so consent always starts from a real endpoint call. The [Google OAuth guide](/guides/google-oauth) carries the full contract - billing, scope gating, and test mocks. | Endpoint | Method | Purpose | | ---------------------------------------------- | -------- | -------------------------------------------------- | | `/api/integrations/status` | `GET` | Per-scope connection flags for all OAuth providers | | `/api/integrations/oauth/:provider/disconnect` | `DELETE` | Revokes the current user's stored tokens | ```ts const r = await fetch('/api/integrations/status', { headers: { Authorization: `Bearer ${await getAuthToken()}` }, }) const { google } = await r.json() // { // connected: boolean, // gmailSend: boolean, gmailRead: boolean, gmailModify: boolean, // calendar: boolean, drive: boolean, contacts: boolean, // gmail: boolean, // aggregate: gmailSend || gmailRead // email?: string // connected account email, when known // } ``` Broader scopes imply narrower ones, never the reverse - gate each feature on its own flag ([implication rules](/guides/google-oauth#connection-status)). ### `requiresOAuth` response Calls lacking tokens or a required scope return the OAuth-required payload as a **normal success result** - HTTP 200, nested under `data`: ```ts { success: true, data: { requiresOAuth: true, provider: 'google', scopes: string[], authUrl: string } } ``` Detection (`result.data?.requiresOAuth`, never `success === false`), the unwrap pattern, and the recovery flow live in the [`requiresOAuth` contract](/guides/google-oauth#the-requiresoauth-response-is-success-shaped). ## See also * [External APIs guide](/guides/external-apis) - patterns, billing routing, and discovery. * [Google OAuth guide](/guides/google-oauth) - the per-user consent contract for `google/*`. * [LiveKit rooms guide](/guides/livekit) - room lifecycle and reserve-then-settle billing. * [CLI integrations command](/cli-reference/commands#integrations) - `list` / `info` / `invoke`. * [AI chat guide](/guides/ai-chat) - streamed LLM responses with tool use (uses a different pipeline). Source: /sdk-reference/client/integrations.md --- # Payments reference `useSubscription`, `useCheckout`, `<PricingTable />`, and the server helpers. ```ts // Client import { useSubscription, useCheckout, PricingTable } from 'deepspace' // Server (worker) import { requireSubscription, getSubscription, cancelSubscription, refundInvoice, SubscriptionAuthError, SubscriptionRequiredError, RefundError, CancelSubscriptionError, } from 'deepspace/server' ``` ## `useSubscription()` ```ts type SubscriptionStatus = | 'none' | 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'incomplete_expired' | 'unpaid' | 'paused' type PlanPrice = { interval: 'month' | 'year'; priceCents: number; currency?: string } type PlanInfo = { slug: string rank: number name: string trialDays?: number | null prices: PlanPrice[] } type SubscribeResult = { url: string | null immediate: boolean requiresPayment?: boolean hostedInvoiceUrl?: string | null } function useSubscription(): { // State tier: string // current plan slug; 'free' if no plan status: SubscriptionStatus entitled: boolean // true iff status is active or trialing interval: 'month' | 'year' | null currentPeriodEnd: number | null // Unix milliseconds - pass straight to new Date() cancelAtPeriodEnd: boolean trialEndsAt: number | null // Unix milliseconds plans: PlanInfo[] // catalog from /me; pass straight to <PricingTable> isLoading: boolean error: string | null // Predicates hasTier: (slug: string) => boolean // strict slug match AND entitled isAtLeast: (slug: string) => boolean // rank ≥ target AND entitled // Actions - both navigate the browser to Stripe Checkout / portal automatically subscribe: (planSlug: string, opts?: { interval?: 'month' | 'year'; returnUrl?: string; cancelUrl?: string }) => Promise<SubscribeResult> openPortal: (returnUrl?: string) => Promise<{ url: string }> // Refresh refresh: () => Promise<void> } ``` `subscribe` and `openPortal` redirect the page when they receive a URL from the server. Even though they return a promise, the page will usually navigate away before it resolves - only `await` them when you specifically need to inspect the result (e.g., `immediate: true` in-place plan changes). **Gate features on `hasTier` / `isAtLeast`, never on `tier` alone.** A `past_due` Pro subscriber keeps `tier === 'pro'` but `entitled === false`. ## `useCheckout({ productId?, ... })` ```ts type ChargeOnceResult = { url: string } type Purchase = { id: string productId: string | null name: string amount: number // cents, post-tax (Stripe's amount_total) currency: string paidAt: string // ISO timestamp } function useCheckout(options?: { productId?: string }): { chargeOnce: (opts: | { productId: string; returnUrl?: string; cancelUrl?: string } | { amount: number; name: string; description?: string; returnUrl?: string; cancelUrl?: string; productId?: never } ) => Promise<ChargeOnceResult> isLoading: boolean error: string | null purchases: Purchase[] /** True iff `options.productId` was passed AND a matching non-refunded purchase exists. A full refund removes the purchase from `purchases` and revokes `owned`; a partial refund keeps both. */ owned: boolean ownsProduct: (productId: string) => boolean refresh: () => Promise<void> } ``` Two modes: * **Product mode** - pass `{ productId }` to both the hook and `chargeOnce`. The server resolves the amount and name from your declared product catalog. Entitlement is checked via `owned` / `ownsProduct`. * **Ad-hoc mode** - pass `{ amount, name }` (and optionally `description`) to `chargeOnce`. The purchase has `productId: null` and **cannot** be used to gate features. `chargeOnce` redirects the browser to Stripe Checkout on success, so callers usually don't see the promise resolve - `await` is only meaningful when you want to inspect the URL or surface an error before redirecting. ## `<PricingTable plans={...} onSelect={...} />` A ready-made pricing UI wired to the plan catalog. Stateless - pass the plan catalog in and call `subscribe()` from your `onSelect` handler. ```tsx import { PricingTable, useSubscription } from 'deepspace' function Pricing() { const { plans, tier, subscribe } = useSubscription() return ( <PricingTable plans={plans} currentTier={tier} onSelect={(slug, interval) => subscribe(slug, { interval })} /> ) } ``` | Prop | Type | Description | | ------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `plans` | `PlanInfo[]` | Plans from `useSubscription().plans`. | | `interval` | `'month' \| 'year'` *(optional)* | Which interval to show prices for. Defaults to `'month'`. | | `currentTier` | `string` *(optional)* | The viewer's current plan slug - disables that plan's button and labels it "Current plan". | | `onSelect` | `(planSlug: string, interval: 'month' \| 'year') => void \| Promise<void>` | Called when the user clicks a plan's button. Wire this to `useSubscription().subscribe`. | ## Server helpers ### `requireSubscription(c, opts)` Throws if the caller's subscription doesn't meet the requirement. Use inside Hono route handlers. ```ts type SubscriptionRead = { tier: string status: SubscriptionStatus currentPeriodEnd: number | null // Unix milliseconds cancelAtPeriodEnd: boolean trialEndsAt: number | null // Unix milliseconds plans: PlanInfo[] } function requireSubscription( c: Context, opts: { tier?: string; atLeast?: string }, ): Promise<SubscriptionRead> class SubscriptionAuthError extends Error { // 401-shaped - identity failure readonly status: number } class SubscriptionRequiredError extends Error { // 402-shaped - tier failure readonly required: string readonly current: string } ``` Both `tier` and `atLeast` are optional. Pass `tier` for a strict slug match, `atLeast` for a rank-or-higher check, or both. The helper always enforces the entitlement gate first (status must be `active` or `trialing` - `free` always passes). ```ts app.get('/api/premium', async (c) => { try { await requireSubscription(c, { atLeast: 'pro' }) } catch (e) { if (e instanceof SubscriptionAuthError) return c.json({ error: 'unauthenticated' }, 401) if (e instanceof SubscriptionRequiredError) return c.json({ error: 'upgrade_required', required: e.required }, 402) throw e } // protected logic }) ``` ### `getSubscription(c)` Read-only variant. Throws `SubscriptionAuthError` for 401/403 from the api-worker, or a generic `Error` for other failures. ```ts function getSubscription(c: Context): Promise<SubscriptionRead> ``` ### `cancelSubscription(c, opts)` Cancel one user or every user on a retired plan. Requires the inbound request to carry the app-owner's JWT - the api-worker rejects anyone else. ```ts function cancelSubscription( c: Context, opts: { userId?: string // mutually exclusive with planSlug planSlug?: string // mutually exclusive with userId atPeriodEnd?: boolean // default true reason?: string // optional audit string }, ): Promise<{ success: boolean canceled: number failures: Array<{ stripeSubscriptionId: string; error: string }> atPeriodEnd: boolean hasMore: boolean }> class CancelSubscriptionError extends Error { readonly status: number } ``` Batched at 50. Loop the call while `hasMore` is true - `cancel_at_period_end` is idempotent so re-flagging is a no-op. ### `refundInvoice(c, opts)` Refund a charge by its local invoice ID (not the Stripe `inv_xxx` ID). ```ts function refundInvoice( c: Context, opts: { invoiceId: string amount?: number // cents; full refund if omitted reason?: 'requested_by_customer' | 'duplicate' | 'fraudulent' requestNonce?: string // idempotency key; auto-generated if omitted }, ): Promise<{ success: boolean stripeRefundId: string amountRefunded: number status: 'pending' | 'succeeded' | 'failed' | 'canceled' | 'requires_action' | null }> class RefundError extends Error { readonly status: number } ``` Constraints: 90-day window, 50 per 24h per app, no overdraw. As with `cancelSubscription`, the inbound request must carry the app-owner's JWT - the platform rejects anyone else with a 403 and error code `not_app_owner`. ## Plan manifest types Declared in `src/subscriptions.ts` and `src/products.ts`: ```ts type SubscriptionPlan = { slug: string name: string priceCents: number // monthly; 0 = free tier yearlyCents?: number trialDays?: number // max 365 taxCode?: string // defaults to 'txcd_10000000' (digital services); one per plan } type OneTimeProduct = { productId: string // stable entitlement key name: string amountCents: number // min 100 ($1.00) description?: string } ``` ## See also * [Payments guide](/guides/payments) - patterns and worked examples * [Server actions guide](/guides/server-actions) - wrap refunds in admin routes Source: /sdk-reference/client/payments.md --- # Theming reference Theme providers, helpers, and the user color palette. DeepSpace ships 15 ready-made theme presets and CSS-variable-based theming. Most apps pick a preset with `<html data-theme="...">` and never touch the runtime API. The exports below are for advanced cases such as applying themes dynamically or building a theme picker. ```ts import { DeepSpaceThemeProvider, useIsDarkTheme, isDarkColor, applyDeepSpaceTheme, clearDeepSpaceTheme, readThemeFromDOM, applyUIThemeTokens, DEEPSPACE_THEME_PROPERTIES, DEFAULT_USER_COLORS, getUserColor, } from 'deepspace' ``` ## `<DeepSpaceThemeProvider theme={...}>` Wraps the tree with theme tokens. Reads from `--color-*` CSS variables by default - usually you don't need this; the `[data-theme]` attribute on `<html>` does the work. Reach for it when: * Applying SDK component tokens inside an independently themed subtree * Switching themes at runtime from React state * Building a theme picker UI ## Hooks and utilities | Export | Signature | Description | | -------------------- | ---------------------------- | -------------------------------------------- | | `useIsDarkTheme()` | `() => boolean` | True if the current theme is a dark variant. | | `isDarkColor(color)` | `(color: string) => boolean` | Luminance check on a single color value. | ## Imperative API ```ts type DeepSpaceThemeConfig = { primaryColor: string panelColor?: string secondaryColor: string accentColor: string accentContrastColor?: string // default '#ffffff' textColor: string shadowColor?: string // default '#000000' borderColor?: string // defaults to secondaryColor backgroundColor?: string // defaults to primaryColor highlightColor?: string // default '#ffffff' glassmorphism?: boolean // default true } applyDeepSpaceTheme(config: DeepSpaceThemeConfig, root?: HTMLElement): void clearDeepSpaceTheme(root?: HTMLElement): void readThemeFromDOM(root?: HTMLElement): DeepSpaceThemeConfig applyUIThemeTokens( theme: 'light' | 'dark', root?: HTMLElement, accent?: { color: string; hover: string; secondary: string }, ): void ``` | Function | Purpose | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `applyDeepSpaceTheme` | Sets the `--theme-*` CSS variables consumed by SDK components. | | `clearDeepSpaceTheme` | Removes theme variables. | | `readThemeFromDOM` | Inspects `--color-*` tokens and derives a theme config. | | `applyUIThemeTokens` | Sets the static `--ui-*` tokens (surfaces, text, borders) for the chosen mode. The optional `accent` overrides the accent/hover/secondary triplet - if omitted, the current `--theme-accent` / `--theme-secondary` are read from computed styles. | `DEEPSPACE_THEME_PROPERTIES` is the readonly tuple of CSS custom properties the theme defines - useful for introspection or building custom clear logic. ## User colors ```ts // 12-color palette for cursors, avatars, and "who's typing" pills const DEFAULT_USER_COLORS: readonly string[] // Deterministic hash: same userId always returns the same color function getUserColor(userId: string, palette?: string[]): string ``` ```tsx import { getUserColor } from 'deepspace' <div style={{ color: getUserColor(peer.userId) }}>{peer.userName}</div> ``` Pass a custom palette to match your brand: ```ts const color = getUserColor(userId, ['#1D4ED8', '#16A34A', '#DC2626']) ``` ## UI primitives (SDK-provided) The SDK ships a small set of UI primitives, but the scaffolded app usually includes its own copies in `src/components/ui/`. Check `_app.tsx` to see which `ToastProvider` is wrapped in the tree before importing `useToast`. **Mixing SDK and local contexts produces `useToast must be used within ToastProvider`.** The scaffold's local `src/components/ui/` ships its own React contexts; the SDK's hooks only find providers from the same module instance. Import primitives from `../components/ui` (local), not from `deepspace`, unless `_app.tsx` is wrapped in the SDK's `ToastProvider`. | Export | Description | | --------------- | ----------------------------------------------------------------------- | | `ToastProvider` | Context for toasts (SDK version) | | `useToast()` | Returns `{ success, error, warning, info, toast, dismiss, dismissAll }` | ## Theme presets reference The scaffold's 15 presets are defined in `src/themes.css` with a typed catalog in `src/themes.ts`. Pick by setting `<html data-theme="...">`: **Dark themes:** `slate` (default), `ink`, `aurora`, `midnight`, `forest`, `ember`, `graphite`, `noir` **Light themes:** `linen`, `mist`, `sand`, `bloom`, `paper`, `lavender`, `citrus` For customization (overriding tokens, adding new presets, light/dark toggles), see the scaffold's `themes.ts` header comments. ## See also * [Get started → Project structure](/get-started/project-structure) - theme files in the scaffold * [Real-time reference](/sdk-reference/client/realtime) - `getUserColor` for cursors Source: /sdk-reference/client/theming.md --- # Rooms reference Durable Object base classes - RecordRoom, YjsRoom, CanvasRoom, PresenceRoom, CronRoom, and JobRoom. The SDK ships six specialized Durable Object base classes plus their shared `BaseRoom` parent. Subclass them in `worker.ts`, declare them in `__DO_MANIFEST__`, and the SDK handles WebSocket upgrades, RBAC, persistence, and broadcast. ```ts import { BaseRoom, RecordRoom, YjsRoom, CanvasRoom, PresenceRoom, CronRoom, JobRoom, } from 'deepspace/worker' import type { RecordRoomConfig, CronRoomConfig, CronTask, CronExecution, CanvasShape, Viewport, PresencePeer, UserAttachment, Job, JobContext, } from 'deepspace/worker' ``` Each base class is parameterized over your `Env` interface so `this.env.<binding>` is typed inside overrides. ## `BaseRoom<E>` Abstract parent of all rooms. Provides WebSocket plumbing, JWT identity parsing, and the connection lifecycle. Subclass directly only when none of the specialized rooms fit - rare in practice. ```ts abstract class BaseRoom<E = Record<string, unknown>> { constructor(state: DurableObjectState, env: unknown) // Lifecycle hooks subclasses override protected onConnect( ws: WebSocket, user: UserAttachment, ): UserAttachment | void | Promise<UserAttachment | void> protected abstract onMessage( ws: WebSocket, user: UserAttachment, message: { type: string; [key: string]: unknown }, ): void | Promise<void> protected onBinaryMessage?( ws: WebSocket, user: UserAttachment, data: ArrayBuffer, ): void | Promise<void> protected onDisconnect( ws: WebSocket, user: UserAttachment, ): void | Promise<void> protected onRequest?(request: Request): Response | Promise<Response> protected onAlarm?(): void | Promise<void> } interface UserAttachment { userId: string userName: string userEmail: string userImageUrl?: string // Subclass-specific data is serialized alongside user info [key: string]: unknown } ``` `onConnect` may return an augmented `UserAttachment` - the returned value (or the default) is serialized on the WebSocket via `state.acceptWebSocket(...)` and survives DO hibernation. ## `RecordRoom<E>` Primary data DO - backs every record collection your app declares. ```ts class RecordRoom<E = Record<string, unknown>> extends BaseRoom<E> { constructor( state: DurableObjectState, env: unknown, schemas?: CollectionSchema[], config?: RecordRoomConfig, ) } interface RecordRoomConfig { /** User ID of the app owner. Automatically gets the `admin` role on connect. */ ownerUserId?: string } ``` Scaffold pattern: ```ts export class AppRecordRoom extends RecordRoom<Env> { constructor(state: DurableObjectState, env: Env) { super(state, env, schemas, { ownerUserId: env.OWNER_USER_ID }) } } ``` ## `YjsRoom<E>` Per-document collaborative state (Y.Text, Y.Map, Y.Array). ```ts class YjsRoom<E = Record<string, unknown>> extends BaseRoom<E> { constructor(state: DurableObjectState, env: unknown) } ``` Connected to via `/ws/yjs/:docId`. The DO persists the full Yjs update as a single binary blob in SQLite. ## `CanvasRoom<E>` Collaborative canvas - shapes and viewports. ```ts class CanvasRoom<E = Record<string, unknown>> extends BaseRoom<E> { constructor(state: DurableObjectState, env: unknown) } interface CanvasShape { id: string type: string x: number y: number width: number height: number rotation?: number props: Record<string, unknown> createdBy: string createdAt: string updatedAt: string } interface Viewport { userId: string x: number y: number width: number height: number zoom: number } ``` Connected to via `/ws/canvas/:docId`. ## `PresenceRoom<E>` Ephemeral peer state - cursors, typing indicators, viewports. **Not persisted.** ```ts class PresenceRoom<E = Record<string, unknown>> extends BaseRoom<E> { constructor(state: DurableObjectState, env: unknown) } interface PresencePeer { userId: string userName: string userEmail: string userImageUrl?: string joinedAt: string /** Arbitrary per-user state (cursor, typing, viewport, etc.) */ state: Record<string, unknown> } ``` Connected to via `/ws/presence/:scopeId`. ## `CronRoom<E>` Scheduled-task DO. Declare tasks in the constructor config and override `onTask`. ```ts abstract class CronRoom<E = Record<string, unknown>> extends BaseRoom<E> { constructor(state: DurableObjectState, env: unknown, config: CronRoomConfig) protected abstract onTask(taskName: string): void | Promise<void> } interface CronRoomConfig { tasks: CronTask[] } interface CronTask { name: string /** Interval in minutes - mutually exclusive with `schedule`. */ intervalMinutes?: number /** 5-field cron expression - requires `timezone`. */ schedule?: string /** IANA timezone string (e.g. "America/New_York"). Required with `schedule`. */ timezone?: string /** Whether the task starts paused. */ paused?: boolean } interface CronExecution { taskName: string startedAt: string /** Null while the task is still running. */ completedAt: string | null success: boolean durationMs: number error?: string } ``` Scaffold pattern: ```ts export class AppCronRoom extends CronRoom<Env> { constructor(state: DurableObjectState, env: Env) { super(state, env, { tasks: cronTasks }) } protected async onTask(name: string) { await runCronTask(name, this.env) } } ``` Connected to via `/ws/cron/:roomId` (admin/monitor stream). ## `JobRoom<E>` Durable background-job DO. Handlers run on the DO's alarm with a \~15-minute wall budget per tick (chain longer jobs with `ctx.continue(state)`). Jobs are persisted in SQLite, survive isolate restarts, and broadcast every state change over WebSocket - clients see live progress without polling. For worked patterns, see the [Background jobs guide](/guides/background-jobs). ```ts abstract class JobRoom< E = Record<string, unknown>, P = unknown, R = unknown, > extends BaseRoom<E> { constructor(state: DurableObjectState, env: unknown, config?: JobRoomConfig) protected abstract onJob(job: Job<P>, ctx: JobContext): R | void | Promise<R | void> } interface JobRoomConfig { /** Default retry budget for jobs that don't pass `maxAttempts` to `enqueue`. Default 1 (no auto-retry). */ defaultMaxAttempts?: number /** How long terminal rows (succeeded / failed / canceled) are kept. Default 24h. */ retentionMs?: number /** Default retry backoff for failed jobs. Default 1000ms. */ retryBackoffMs?: number /** How many historical rows the DO holds in its in-memory snapshot before evicting. Default 100. */ snapshotLimit?: number } interface Job { id: string type: string payload?: unknown status: 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled' attempts: number maxAttempts: number /** Return value of a successful `onJob`. */ result?: unknown /** Error message from a thrown `onJob`. */ error?: string /** Last `ctx.progress(value)` value (0..1). Present while live. */ progress?: number /** Last `ctx.progress(_, message)` string. */ progressMessage?: string enqueuedAt: string startedAt?: string | null completedAt?: string | null enqueuedBy?: string | null /** State checkpoint from a previous `ctx.continue(state)` call. */ resumeFrom?: unknown } interface JobContext { /** Broadcast progress (0..1) and an optional human-readable message. */ progress(value: number, message?: string): void /** AbortSignal that fires on client cancel. Pass to `fetch(url, { signal })`. */ signal: AbortSignal /** Checkpoint state and yield to the next alarm tick. Call this then `return`. */ continue(state: unknown, opts?: { afterMs?: number }): void } ``` The `onJob` return value becomes `job.result` (must be JSON-serializable); throwing fails the job and triggers a retry if `attempts < maxAttempts`. There is no `ctx.complete()` / `ctx.fail()` - return or throw. Sync returns are allowed - `onJob` does not have to be `async`. `P` and `R` narrow the job payload and result types: `JobRoom<Env, MyPayload, MyResult>` gives you `onJob(job: Job<MyPayload>, ctx): MyResult | void | Promise<MyResult | void>`. Most apps leave them as `unknown` and cast at the dispatch site (see the scaffold pattern below). Scaffold pattern: ```ts export class AppJobRoom extends JobRoom<Env> { constructor(state: DurableObjectState, env: Env) { super(state, env) } protected async onJob(job: Job, ctx: JobContext): Promise<unknown> { return await runJob(job, ctx, this.env) } } ``` Connected to via `/ws/jobs/:roomId`. For the worker-side enqueue helper that lets you enqueue from HTTP routes, cron handlers, or server actions (different isolate from the DO), see [`enqueueJob`](#enqueuejob-namespace-roomid-type-payload-opts) below. ### `enqueueJob(namespace, roomId, type, payload?, opts?)` Cross-isolate enqueue helper - use anywhere outside the JobRoom DO (HTTP routes, cron handlers, server actions, AI routes). ```ts function enqueueJob( namespace: DurableObjectNamespace, roomId: string, type: string, payload?: unknown, opts?: { maxAttempts?: number; enqueuedBy?: string }, ): Promise<string> // resolves with the jobId ``` Pass `app:${env.DEEPSPACE_APP_ID}` as `roomId` to hit the per-app `AppJobRoom` - room ids key to the immutable app id, never the app name ([why](/concepts/data-model#scopes)). From inside an `onJob` handler, call `this.enqueue(...)` directly instead - it skips the HTTP hop. > Audio/video rooms have no SDK DO class. Use LiveKit via the `livekit/*` integration endpoints instead. ## The DO manifest ```ts import type { DOManifest, DOManifestEntry, DOBindings } from 'deepspace/worker' ``` | Export | Type | Description | | ------------------------------------ | ----- | ---------------------------------------------------------------------------------------------------- | | `DOManifest` | type | `DOManifestEntry[]` - shape of `__DO_MANIFEST__`. | | `DOManifestEntry` | type | `{ binding: string; className: string; sqlite: boolean }`. | | `DOBindings<typeof __DO_MANIFEST__>` | type | Derives the `Env` interface's DO bindings from the manifest. | | `DEFAULT_DO_MANIFEST` | const | Two-entry fallback (`RECORD_ROOMS` + `YJS_ROOMS`) used when an app doesn't export `__DO_MANIFEST__`. | The scaffold's pattern: ```ts export const __DO_MANIFEST__ = [ { binding: 'RECORD_ROOMS', className: 'AppRecordRoom', sqlite: true }, { binding: 'YJS_ROOMS', className: 'AppYjsRoom', sqlite: true }, { binding: 'CANVAS_ROOMS', className: 'AppCanvasRoom', sqlite: true }, { binding: 'PRESENCE_ROOMS', className: 'AppPresenceRoom', sqlite: true }, { binding: 'CRON_ROOMS', className: 'AppCronRoom', sqlite: true }, { binding: 'JOB_ROOMS', className: 'AppJobRoom', sqlite: true }, ] as const satisfies DOManifest interface Env extends DOBindings<typeof __DO_MANIFEST__> { // ...secrets and custom bindings } ``` ## See also * [Architecture concepts](/concepts/architecture) - how DOs fit into the system * [Get started → Project structure](/get-started/project-structure) - the DO manifest in context * [Cron guide](/guides/scheduled-jobs) - patterns for `CronRoom` Source: /sdk-reference/worker/rooms.md --- # Schemas reference `CollectionSchema`, RBAC types, and the pre-built drop-in collections. Schemas describe collections - their columns, permissions, and visibility rules. They're declared in `src/schemas/`, registered in `src/schemas.ts`, and baked into the worker at deploy time. ```ts import type { CollectionSchema, ColumnDefinition, ColumnInterpretation, RolePermissions, PermissionLevel, } from 'deepspace/schema' import { USERS_COLUMNS, BASE_USERS_SCHEMA, CHANNELS_SCHEMA, MESSAGES_SCHEMA, REACTIONS_SCHEMA, CHANNEL_MEMBERS_SCHEMA, READ_RECEIPTS_SCHEMA, AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA, } from 'deepspace/schema' // Worker-internal collection names are only needed in worker code: import { SYSTEM_COLLECTIONS } from 'deepspace/worker' // Role constants live on the client entry point (shared module): import { ROLES, ROLE_CONFIG, type Role } from 'deepspace' ``` **Schemas imported by browser or shared code belong on `deepspace/schema`.** That runtime-neutral entry point exports the schema types and the users, messaging, and AI chat schemas without pulling worker runtime code into the browser bundle. Reserve `deepspace/worker` for worker-only helpers. ```ts // src/schemas/items-schema.ts — safe to import from React components import type { CollectionSchema } from 'deepspace/schema' ``` ## `CollectionSchema` ```ts interface CollectionSchema { name: string /** Column definitions - every collection is stored in a typed SQL table. */ columns: ColumnDefinition[] /** * Composite uniqueness constraint (e.g., ['userId', 'taskId']). Enforced in * the DO on every write path: a duplicate is refused - the write's ack * carries `success: false` with "Duplicate: a record with userId=…, taskId=… * already exists in <collection>" - so one-per-user rules (votes, RSVPs) * hold against forged client writes when combined with a `userBound` column. */ uniqueOn?: string[] /** Column name used for ownership checks (default: `_created_by`). */ ownerField?: string /** Column containing JSON array of collaborator user IDs. */ collaboratorsField?: string /** Column containing team ID for team-based access. */ teamField?: string /** * Column controlling per-record read visibility. * String form: visible when `data[field] === 'public'`. * Object form: visible when `data[field] === value`. */ visibilityField?: string | { field: string; value: unknown } /** Permissions per role. Use `'*'` for a catch-all fallback. */ permissions: Record<string, RolePermissions> /** Default role for new users (only on the `users` collection). */ defaultRole?: string } ``` ## `ColumnDefinition` ```ts interface ColumnDefinition { /** Stable ID override (survives renames). Falls back to `col_{name}`. */ id?: string name: string storage: 'number' | 'text' interpretation: ColumnInterpretation | string /** SQL expression for a computed column (read-only). */ expression?: string /** Auto-populate with the current user ID on create. */ userBound?: boolean /** Cannot be changed after initial creation. */ immutable?: boolean /** Must be provided on create (non-null). */ required?: boolean /** Default value if not provided on create. */ default?: unknown /** Auto-set ISO timestamp when the named field changes (optionally to a specific value). */ timestampTrigger?: { field: string; value?: unknown } } ``` `storage` is `'number' | 'text'` - these are the only two backing SQLite types the SDK uses. `interpretation` can be either a string shortcut (e.g. `'plain'`) or a discriminated-union object. ### The empty string is a value On a `'text'` column, `''` is stored as `''` and reads back as `''`. It is a value, not an absence - clearing a field writes an empty string rather than deleting the key, and `default: ''` actually materializes on create. (Earlier versions folded `''` to `NULL` on the way in and then dropped the key on the way out, so a field written as `''` read back `undefined` and a `default: ''` never appeared at all.) On a `'number'` column there is nothing to store for `''`, so it stays `NULL`. ### `timestampTrigger` writes the column's declared type The trigger writes whatever the column's `storage` says it holds: an ISO 8601 string on a `'text'` column, and **epoch milliseconds** (`Date.now()`) on a `'number'` column. Declare the storage you want to read back - a `'number'` column previously received the ISO string and stored whatever `Number()` made of it, which is how a `completedAt` ended up holding `2026`. ## `ColumnInterpretation` ```ts type ColumnInterpretation = | { kind: 'plain' } | { kind: 'currency'; symbol: string; decimals: number } | { kind: 'date'; format?: string } | { kind: 'datetime'; format?: string } | { kind: 'boolean'; trueLabel?: string; falseLabel?: string } | { kind: 'percent'; decimals?: number } | { kind: 'select'; options: string[] } | { kind: 'multiselect'; options: string[] } | { kind: 'url' } | { kind: 'email' } | { kind: 'json' } | { kind: 'reference'; targetTable: string; displayColumn: string } ``` Some kinds carry required fields - `currency` needs `symbol` and `decimals`; `select` and `multiselect` need `options`; `reference` needs `targetTable` and `displayColumn`. The bare `string` form on `ColumnDefinition.interpretation` is a shortcut for `{ kind: <string> }`. ## `RolePermissions` ```ts interface RolePermissions { read: PermissionLevel create: boolean update: PermissionLevel delete: PermissionLevel /** If set, this role may only supply the listed columns on create or update. */ writableFields?: string[] } type PermissionLevel = | boolean | 'own' | 'unclaimed-or-own' | 'collaborator' | 'team' | 'access' | 'published' | 'shared' ``` All four of `read`, `create`, `update`, `delete` are required on every role entry. `create` is boolean-only (you either can or can't create new rows for a role); the others accept the full `PermissionLevel` union. `writableFields` restricts **both create and update** - the worker runs the same field check on both write paths, rejecting any caller-supplied column outside the list. On update, re-sending a field with its unchanged value is allowed; server-owned writes (column `default`s, `userBound` stamps, `timestampTrigger`s) never count against the list. See [Concepts → Permissions](/concepts/permissions) for the semantics of each level. ## Roles ```ts // Imported from `deepspace` (client entry), NOT `deepspace/worker`: import { ROLES, ROLE_CONFIG, type Role } from 'deepspace' const ROLES: { VIEWER: 'viewer'; MEMBER: 'member'; ADMIN: 'admin' } type Role = 'viewer' | 'member' | 'admin' const ROLE_CONFIG: Record<Role, { title: string badgeVariant: 'secondary' | 'default' | 'warning' description: string }> ``` `ROLES` gives you the three string identifiers used in `permissions` blocks. `ROLE_CONFIG` is display metadata for role-badge UIs. For unauthenticated users, use the `'*'` key in permissions - there is no `anonymous` role identifier. ## Drop-in schemas - Users ```ts const USERS_COLUMNS: ColumnDefinition[] // canonical users columns const BASE_USERS_SCHEMA: CollectionSchema // assembled from USERS_COLUMNS ``` The scaffold's `usersSchema` extends `BASE_USERS_SCHEMA`. Don't replace; extend if you need extra columns. ## Drop-in schemas - Messaging | Schema | Collection name | Purpose | | ------------------------ | ----------------- | -------------------------------- | | `CHANNELS_SCHEMA` | `channels` | Public channel definitions | | `MESSAGES_SCHEMA` | `messages` | Channel messages | | `REACTIONS_SCHEMA` | `reactions` | Emoji reactions on messages | | `CHANNEL_MEMBERS_SCHEMA` | `channel-members` | Opt-in public-channel membership | | `READ_RECEIPTS_SCHEMA` | `read-receipts` | Per-user per-channel read state | Add to your app's `schemas` array to enable the corresponding hooks (`useChannels`, `useMessages`, etc.). These schemas intentionally model public chat only. For private messaging, define app-owned collections with a `collaboratorsField` and server-enforced `read: 'shared'`; do not rely on membership filtering in the UI. ## Drop-in schemas - AI chat ```ts const AI_CHATS_SCHEMA: CollectionSchema // 'ai-chats' const AI_MESSAGES_SCHEMA: CollectionSchema // 'ai-messages' ``` RBAC: members `read/update/delete: 'own'`, `create: false` - writes only flow through the worker's chat routes. **Don't relax `create` to `true`** (see the AI chat guide for why). ## System collection names ```ts const SYSTEM_COLLECTIONS: Set<string> ``` `SYSTEM_COLLECTIONS` contains SDK-internal names used by worker handlers. Most apps never need it. ## Pattern: a typical schema ```ts import type { CollectionSchema } from 'deepspace/worker' export const itemsSchema: CollectionSchema = { name: 'items', columns: [ { name: 'title', storage: 'text', interpretation: 'plain' }, { name: 'status', storage: 'text', interpretation: { kind: 'select', options: ['draft', 'published'] }, }, { name: 'tags', storage: 'text', interpretation: { kind: 'json' } }, ], visibilityField: { field: 'status', value: 'published' }, permissions: { '*': { read: 'published', create: false, update: false, delete: false }, member: { read: true, create: true, update: 'own', delete: 'own' }, admin: { read: true, create: true, update: true, delete: true }, }, } ``` ## Schema-lint warnings When each schema is registered (worker startup, first DO boot), the SDK runs a lightweight lint and prints any findings to the worker console prefixed `[schema-lint]`. The CLI also runs the same lint up front: `deepspace dev start` and `deepspace deploy` bundle `src/schemas.ts` and print any findings in the terminal as `Schema lint: N warnings in src/schemas.ts`, so you see them before a client ever connects (if the file can't be bundled, the CLI says the lint was skipped and the worker still lints at runtime). Warnings do **not** block boot or deploy - each just flags a declaration that looks like it should enforce something but doesn't. Fix every one before shipping. `lintSchema(schema)` is also re-exported from `deepspace/worker` and returns the warning strings as an array, so you can assert against it in your own tests. ```ts import { lintSchema } from 'deepspace/worker' import { notesSchema } from './schemas/notes-schema' // In a unit test: expect(lintSchema(notesSchema)).toEqual([]) ``` ### 1. `visibilityField` declared but no role uses `'published'` / `'shared'` ``` [<collection>] visibilityField is declared but no role uses read: 'published' or 'shared'. Roles with read: true (<roles>) will see every row regardless of visibility. Change those to read: 'published' (owner OR public) or 'shared' (owner OR collaborator OR public) to actually enforce the filter, or remove visibilityField if you don't intend to gate reads by it. ``` **Cause.** You set `visibilityField` (intending per-record gating), but every role with read access has `read: true`. `true` is unconditional - the visibility column is never consulted, so every row is visible to every reader. **Fix.** Either drop `visibilityField`, or change at least one role's `read` to `'published'` (owner OR matches `visibilityField`) or `'shared'` (owner OR collaborator OR matches `visibilityField`). This is a privacy foot-gun, not a typo. A schema that triggers this warning will silently leak draft / private rows to everyone who can read the collection. ### 2. `ownerField` set but the column is not `userBound` ``` [<collection>] ownerField is '<field>' but that column is not marked userBound: true. A client can create a row with someone else's id in this field, bypassing 'own' permission checks. Add userBound: true (and ideally immutable: true) to the column. ``` **Cause.** `ownerField` tells `'own'` permission checks which column to read. Without `userBound: true` on that column, a client can write any user id into it on create - claiming ownership of a row they didn't actually create. **Fix.** Add `userBound: true` (and ideally `immutable: true`) to the named column. `userBound` makes the DO overwrite the field with the caller's verified user id on every write. **When the lint deliberately stays quiet.** The warning fires only when an ordinary client role (anything other than `owner` / `admin`) has `create: true` without `update: 'unclaimed-or-own'`. If every ordinary client role has `create: false`, there is no warning - rows in that collection are written by server code (actions, admin flows), which may legitimately assign the owner column to *another* user (a recipient or subject id). Don't add `userBound` mechanically in that shape: it would stamp the writer's id over the intended owner. ```ts { name: 'todos', columns: [ { name: 'assignedTo', storage: 'text', interpretation: 'plain', userBound: true, immutable: true }, // ... ], ownerField: 'assignedTo', permissions: { member: { read: true, create: true, update: 'own', delete: 'own' }, }, } ``` ### 3. `userBound: true` on a non-`text` column ``` [<collection>] column '<name>' is userBound but storage is '<storage>'. userBound stamps the user id (a string); use storage: 'text'. ``` **Cause.** `userBound` stamps a user id (a string) into the column. The SDK only coerces strings into `'text'` storage; `'number'` will fail at write time. **Fix.** Change the column's `storage` to `'text'`. ## See also * [Data model concepts](/concepts/data-model) - collections and envelopes * [Permissions concepts](/concepts/permissions) - rules and visibility * [Records reference](/sdk-reference/client/records) - client-side hooks Source: /sdk-reference/worker/schemas.md --- # Server actions reference `ActionHandler`, `ActionContext`, `ActionTools`, and `ActionResult`. Server actions are defined in `src/actions/index.ts` and exposed at `POST /api/actions/:name`. They run as the app - RBAC checks are bypassed via the `X-App-Action` header - and are the right tool for cross-collection orchestration or owner-gated operations. ```ts import type { ActionHandler, ActionContext, ActionTools, ActionResult, MutateActionData, GetActionData, QueryActionData, } from 'deepspace/worker' ``` ## `ActionHandler<TEnv>` ```ts type ActionHandler<TEnv = Record<string, unknown>> = (ctx: ActionContext<TEnv>) => Promise<ActionResult> ``` Export a record of named actions from `src/actions/index.ts`: ```ts export const actions: Record<string, ActionHandler<Env>> = { inviteAttendee: async ({ params, tools, userId }) => { // ... return { success: true, data: { added: 1 } } }, } ``` The handler name (the key) becomes the endpoint path: `/api/actions/inviteAttendee`. ## `ActionContext<TEnv>` ```ts interface ActionContext<TEnv = Record<string, unknown>> { userId: string // verified JWT subject params: Record<string, unknown> // request body tools: ActionTools env: TEnv // worker bindings, typed callerJwt: string // caller's raw Bearer token } ``` `userId` is the caller - not the app owner. Use it for audit logs and per-caller logic. For owner-only actions, gate on `userId === env.OWNER_USER_ID`. `callerJwt` is the raw, already-verified Bearer token the action was invoked with. Forward it on outbound platform requests that need to act as the caller rather than as the app owner - for example, deploy-worker `/api/apps` ownership checks, or any `apiWorkerFetch` / `platformWorkerFetch` call where the upstream bills or authorizes the JWT subject. See [Forwarding caller identity](/guides/server-actions#forwarding-caller-identity) in the guide. Never log `callerJwt` or echo it into response bodies. It's a live credential for the caller's session - treat it the same way you'd treat a password. Pass it to upstream workers via an `Authorization: Bearer …` header and nothing else. ## `ActionTools` ```ts interface ActionTools { create<T extends Record<string, unknown> = Record<string, unknown>>( collection: string, data: T, recordId?: string, ): Promise<ActionResult<MutateActionData>> update<T extends Record<string, unknown> = Record<string, unknown>>( collection: string, recordId: string, data: Partial<T>, ): Promise<ActionResult<MutateActionData>> remove( collection: string, recordId: string, ): Promise<ActionResult<MutateActionData>> get<T extends Record<string, unknown> = Record<string, unknown>>( collection: string, recordId: string, ): Promise<ActionResult<GetActionData<T>>> query<T extends Record<string, unknown> = Record<string, unknown>>( collection: string, options?: { where?: Record<string, unknown> orderBy?: string orderDir?: 'asc' | 'desc' limit?: number }, ): Promise<ActionResult<QueryActionData<T>>> integration<T = unknown>( endpoint: string, data?: unknown, ): Promise<ActionResult<T>> registerUser(opts: { userId?: string name?: string email?: string imageUrl?: string isAdmin?: boolean }): Promise<ActionResult<{ user: { id: string; name: string; email: string; imageUrl?: string; role: string } }>> } ``` The per-operation data shapes: ```ts interface MutateActionData { recordId: string } interface GetActionData<T = Record<string, unknown>> { record: RecordResult & { data: T } } interface QueryActionData<T = Record<string, unknown>> { records: Array<RecordResult & { data: T }> count: number } ``` | Method | Returns (under `.data`) | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `tools.get(coll, id)` | `{ record }` - full envelope with `data` typed as `T` | | `tools.query(coll, opts?)` | `{ records, count }` | | `tools.create(coll, data, recordId?)` | `{ recordId }` - pass `recordId` to upsert against a known key (see [Upsert by known id](/guides/server-actions#upsert-by-known-id)) | | `tools.update(coll, id, patch)` | `{ recordId }` | | `tools.remove(coll, id)` | `{ recordId }` | | `tools.integration(endpoint, body)` | The integration's response body directly (typed as `T`) | | `tools.registerUser(opts)` | `{ user }` - the seeded/refreshed users row (see [below](#seeding-users-rows-tools-registeruser)) | `tools.integration` does **not** wrap the response in `{ response }` - on success, `result.data` IS the integration's body. An OpenAI chat call yields `result.data.choices`; a Freepik image call yields `result.data.images`. All operations bypass caller RBAC. **`tools.query` sees every record in the collection** regardless of the caller's role - pass a `where` clause to scope. ## Seeding users rows - `tools.registerUser` Inserts or refreshes a `users` row, mirroring the flow that normally runs when a user's WebSocket connects. Use it in actions whose caller may never have opened the web app and so has no `users` row yet - typically CLI-triggered actions (e.g., publishing via a `deepspace`-invoked action). Unlike `tools.create('users', ...)`, it bypasses the SYSTEM\_MANAGED column stripping, so `name` / `email` / `imageUrl` are actually written. * `userId` defaults to the action's caller (`ctx.userId`). * Existing rows keep their role; `isAdmin: true` forces the role to `'admin'`. Pass it only when the worker has derived admin status from the **verified JWT** - never from client input. ```ts export const publishFromCli: ActionHandler<Env> = async ({ userId, params, tools }) => { // Make sure the caller has a users row before writing rows that reference it. const seeded = await tools.registerUser({ name: params.name as string | undefined, email: params.email as string | undefined, }) if (!seeded.success) return seeded return tools.create('documents', { title: params.title, ownerId: userId }) } ``` ## `ActionResult<T>` ```ts type ActionResult<TData = unknown> = | { success: true; data: TData; error?: never } | { success: false; data?: never; error: string } ``` Narrow with `if (result.success)` before reading `result.data`: ```ts const r = await tools.get<EventData>('events', eventId) if (!r.success) return r const event = r.data.record // typed as RecordResult & { data: EventData } ``` ## Integration billing `tools.integration(endpoint, body)` proxies through the api-worker. Billing follows `src/integrations.ts`: | `billing` setting | Who pays | | ----------------- | --------------------------------- | | `'developer'` | The app owner via `APP_OWNER_JWT` | | `'user'` | The signed-in caller | The api-worker reads the JWT subject - there's no client-supplied override (`X-Billing-User-Id` is ignored). ## Owner-only pattern Gate actions that spend owner resources: ```ts export const recomputeAnalytics: ActionHandler<Env> = async (ctx) => { if (ctx.env.OWNER_USER_ID && ctx.userId !== ctx.env.OWNER_USER_ID) { return { success: false, error: 'Forbidden: owner only' } } // privileged work return { success: true, data: {} } } ``` `OWNER_USER_ID` is set on every deployed app and is the canonical trust anchor for owner-only operations. ## Calling from the client ```ts import { getAuthToken } from 'deepspace' const res = await fetch('/api/actions/inviteAttendee', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await getAuthToken()}`, }, body: JSON.stringify({ eventId, attendeeId }), }) const result = await res.json() as ActionResult ``` The action's `success` / `data` / `error` shape is forwarded verbatim in the HTTP response body. HTTP status is 200 on success, 401 if unauthenticated, 404 if the action name doesn't exist, 500 on uncaught throws. ## Type tip - fetching the post-write envelope `tools.create` / `update` / `remove` resolve to `ActionResult<MutateActionData>` - only `{ recordId }` is returned, never the full envelope. To inspect the row after a mutation, re-fetch it: ```ts const r = await tools.update<EventData>('events', id, patch) if (r.success) { const got = await tools.get<EventData>('events', r.data.recordId) if (got.success) { const event = got.data.record } } ``` ## See also * [Server actions guide](/guides/server-actions) - patterns and worked examples * [Permissions concepts](/concepts/permissions) - what RBAC bypass means * [External APIs guide](/guides/external-apis) - billing routing for `tools.integration` Source: /sdk-reference/worker/server-actions.md --- # AI reference Provider routing, context compaction, chat-history wrappers, and built-in tools. ```ts import { // Provider createDeepSpaceAI, // Compaction prepareMessagesWithCompaction, truncateOldToolResults, applySlidingWindow, capToolResultSize, totalChars, turnsToCoreMessages, buildUiParts, unwrapToolOutput, makeDefaultSummarizer, DEFAULT_CONTEXT_CONFIG, // Chat history (DO tools API wrappers) getChat, createChat, updateChat, deleteChatCascade, loadMessages, appendMessage, // Schemas AI_CHATS_SCHEMA, AI_MESSAGES_SCHEMA, // Built-in tools BUILT_IN_TOOLS, applyAiToolDefaults, DEFAULT_QUERY_LIMIT, } from 'deepspace/worker' import type { DeepSpaceAIEnv, DeepSpaceAIOptions, DeepSpaceModelFactory, ChatContextConfig, ChatTurn, Summarizer, ChatRow, ChatMessageRow, ToolSchema, } from 'deepspace/worker' ``` ## `createDeepSpaceAI(env, provider, options?)` Returns a Vercel AI SDK v5 model factory routed through the DeepSpace API worker. ```ts function createDeepSpaceAI( env: DeepSpaceAIEnv, provider: 'anthropic' | 'openai' | 'cerebras', options?: { authToken?: string }, ): DeepSpaceModelFactory type DeepSpaceModelFactory = (modelId: string) => LanguageModel ``` | Option | Effect | | --------------------- | ---------------------------------------------- | | `authToken` (passed) | Caller pays - JWT subject is billed | | `authToken` (omitted) | Owner pays - falls back to `env.APP_OWNER_JWT` | Use the returned factory with `streamText` / `generateText` from the `ai` package: ```ts import { streamText } from 'ai' const ai = createDeepSpaceAI(env, 'anthropic', { authToken }) const result = await streamText({ model: ai('claude-sonnet-5'), messages, tools, }) ``` ## Context compaction ### `prepareMessagesWithCompaction(messages, config, options)` Pre-stream pipeline that keeps the conversation under the context budget. ```ts function prepareMessagesWithCompaction( messages: ChatTurn[], config: ChatContextConfig, options: { summarizer: Summarizer cachedSummary?: { text: string; throughId: string } }, ): Promise<{ messages: ChatTurn[] newSummary?: { text: string; throughId: string } }> ``` `cachedSummary` is the previous turn's summary (if any), anchored to a known message id. When the helper produces a fresh summary, it returns `newSummary` for persistence - store it on the chat row so the next turn can pass it back as `cachedSummary`. Order of operations: 1. `truncateOldToolResults` - replace old tool-result payloads with a small marker. 2. Apply `cachedSummary` if its `throughId` is found in the history. 3. Summarize the older half if still over budget; return as `newSummary`. 4. Fall back to `applySlidingWindow` on summarizer error or missing message ids. ### `truncateOldToolResults(messages, keepRecent)` Replaces old tool-result payloads with markers; preserves errors (`success: false`) and the `keepRecent` most recent assistant turns intact. ### `applySlidingWindow(messages, charCap, minKept)` Drops oldest messages until under `charCap`, never below `minKept`. System messages are pinned. ### `capToolResultSize(result, byteCap)` Caps individual tool-result payloads with a structured "result too large; narrow your query" error. Preserves a 2KB preview. ### `totalChars(messages)` Sum of `content` + `JSON.stringify(parts)` lengths. ### `DEFAULT_CONTEXT_CONFIG` ```ts const DEFAULT_CONTEXT_CONFIG: ChatContextConfig = { contextBudget: 240_000, // chars ≈ 60–80K tokens toolResultCap: 30_000, // bytes per tool result keepRecentToolResults: 5, minKept: 10, // sliding-window floor } ``` Sized for 200K+ context models (Claude Sonnet/Opus, GPT-4.1). Lower for shorter-context models. ## Format conversions ```ts function turnsToCoreMessages(turns: ChatTurn[]): ModelMessage[] function buildUiParts(responseMessages: ModelMessage[]): unknown[] function unwrapToolOutput(output: unknown): unknown ``` `turnsToCoreMessages` converts persisted UI-shape `ChatTurn` rows into Vercel AI SDK v5 `ModelMessage`s, splitting assistant rows at each tool-call boundary so Anthropic's `tool_use → tool_result` pairing is preserved. `buildUiParts` is the inverse - converts `onFinish` response messages into the flat UI-shape `parts` array we persist on `ai-messages` rows. `unwrapToolOutput` unwraps v5's tagged `output` (`{ type: 'json' | 'text' | ..., value }`) into the flat shape we persist. ## Summarizers ### `makeDefaultSummarizer(env, options?)` Returns a Claude Haiku 4.5 summarizer. ```ts function makeDefaultSummarizer( env: DeepSpaceAIEnv, options?: { authToken?: string }, ): Summarizer ``` Omit `authToken` to bill the owner (compaction as infrastructure cost). Pass the caller's JWT to bill the user (compaction as part of chat cost). The default summary anchors on the last real message ID in the older half (skipping prior-summary system rows so re-summarization doesn't loop) - preserve that anchoring if you replace it. ### `Summarizer` type ```ts type Summarizer = (messages: ChatTurn[]) => Promise<string> ``` Roll your own implementation if you want a different model or strategy. ## Chat history helpers (DO tools API wrappers) These read and write the `ai-chats` and `ai-messages` collections with `X-App-Action: 'true'` (bypassing user RBAC). **The worker is the trust boundary** - callers MUST verify chat ownership before invoking write helpers. ```ts function getChat( stub: DurableObjectStub, chatId: string, userId: string, ): Promise<ChatRow | null> function createChat( stub: DurableObjectStub, userId: string, opts?: { title?: string; model?: string }, ): Promise<ChatRow> function updateChat( stub: DurableObjectStub, chatId: string, userId: string, patch: Partial<Pick<ChatRow, 'title' | 'model' | 'compactedSummary' | 'compactedThroughId'>>, ): Promise<void> function deleteChatCascade( stub: DurableObjectStub, chatId: string, userId: string, ): Promise<void> function loadMessages( stub: DurableObjectStub, chatId: string, userId: string, ): Promise<ChatMessageRow[]> function appendMessage( stub: DurableObjectStub, msg: { id: string chatId: string userId: string role: 'user' | 'assistant' | 'system' content: string parts?: unknown[] }, ): Promise<void> ``` `appendMessage` takes an `id` field that becomes the new row's `recordId` on the underlying tools API. | Type | Shape | | ---------------- | ------------------------------------------------------------------------------------------------------- | | `ChatRow` | `{ recordId, id, userId, title, model?, compactedSummary?, compactedThroughId?, createdAt, updatedAt }` | | `ChatMessageRow` | `{ recordId, id, chatId, userId, role, content, parts?, createdAt }` | Both row shapes expose `recordId` as the canonical identifier and keep `id` as a deprecated alias for backward compatibility. Read `recordId` in new code. ## Built-in tools ```ts const BUILT_IN_TOOLS: ToolSchema[] interface ToolSchema { name: string description: string params: Record<string, { type: 'string' | 'number' | 'boolean' | 'object' | 'array' description: string required?: boolean default?: unknown }> } ``` `BUILT_IN_TOOLS` is an **array** of tool schemas, not a record keyed by name. Each entry declares its parameters as a flat `{ type, description, required?, default? }` map - this is an MCP-like description used by the worker's tools API and by app authors who want to surface SDK tools to an LLM. The catalog (records, schemas, users, storage, backup, Yjs): | Tool | Purpose | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `records.query` | Filter and list records | | `records.get` | Fetch one record | | `records.create` | Create a record | | `records.update` | Patch a record | | `records.delete` | Delete a record | | `records.deleteWhere` | Delete every record matching a filter, one bounded page per call (`{ collection, where, limit }` → `{ deleted }`; repeat until `deleted` < `limit`); same delete permission check as `records.delete`, and a `where` key that names no field is refused, not ignored | | `schema.list` | Enumerate collection names | | `schema.describe` | Describe one collection's columns and permissions | | `user.current` | Look up the caller's user record | | `user.list` | List the users in the room, projected to what the caller may see - full rows for an admin caller, the public-identity projection for everyone else. See [the directory from the server side](/concepts/permissions#the-directory-from-the-server-side) | | `storage.list` / `read` / `write` / `delete` | Key-value storage | | `backup.create` / `list` / `restore` / `delete` | Yjs doc backups | | `yjs.list` / `getText` / `setText` | Collaborative doc text access | See `src/ai/tools.ts` in the scaffold for `buildSystemPrompt(appName, schemas)` and `buildReadOnlyTools(executor)` - both are app-local references you can edit to customize the assistant's tool surface and system prompt. ### `applyAiToolDefaults(toolName, params)` Fills in assistant-only parameter defaults for a built-in tool call, before a **model-issued** call is dispatched to the tools API. ```ts function applyAiToolDefaults( toolName: string, params: Record<string, unknown>, ): Record<string, unknown> const DEFAULT_QUERY_LIMIT = 50 ``` One default: a `records.query` call with no `limit` gets `limit: DEFAULT_QUERY_LIMIT`, so a model-issued unbounded scan can't blow the tool-result byte cap (the model can still raise `limit` and page). The function is pure - it returns a new params object and never mutates its input. Call it yourself if you build a custom tool executor over `BUILT_IN_TOOLS`: ```ts const result = await executeTool(toolName, applyAiToolDefaults(toolName, params)) ``` The default deliberately lives in the AI layer, **not** in the shared tools dispatch - `records.query` doubles as the SDK's general record-read primitive (chat history, cron, app actions), and those internal callers must stay unbounded. ## See also * [AI chat guide](/guides/ai-chat) - end-to-end usage * [Server actions reference](/sdk-reference/worker/server-actions) - `tools.integration` for non-streamed calls Source: /sdk-reference/worker/ai.md --- # Worker cron reference `CronRoom`, `CronTask`, and `buildCronContext`. ```ts import { CronRoom, buildCronContext } from 'deepspace/worker' import type { CronTask, CronExecution, CronRoomConfig, CronContext } from 'deepspace/worker' ``` ## `CronRoom<E>` See [Rooms reference → CronRoom](/sdk-reference/worker/rooms#cronroom-e) for the class signature. The scaffolded `AppCronRoom` extends this and overrides `onTask`. ## `CronTask` ```ts interface CronTask { name: string intervalMinutes?: number // mutually exclusive with schedule schedule?: string // 5-field cron expression timezone?: string // IANA timezone (required with schedule) paused?: boolean // start disabled } ``` Each task declares **either** `intervalMinutes` OR `schedule` + `timezone`. Declaring both or neither throws at DO construction. Cron mode is DST-aware - the wall-clock comparison happens after the timezone shift. ## `CronExecution` ```ts interface CronExecution { taskName: string startedAt: string /** Null while the task is still running. */ completedAt: string | null success: boolean durationMs: number error?: string } ``` Stored in the DO's history and streamed to `useCronMonitor` subscribers. The `lastRunAt` field on a task state row is similarly nullable until the task fires at least once. ## `buildCronContext(env, ownerUserId, roomId?)` Returns a context for use inside `runTask`. Operations run as the app owner, bypassing RBAC. ```ts function buildCronContext( env: { RECORD_ROOMS: DurableObjectNamespace /** Optional in the type, but `ctx.integrations.call(...)` throws at runtime if missing. */ APP_OWNER_JWT?: string API_WORKER?: Fetcher API_WORKER_URL?: string }, ownerUserId: string, roomId?: string, ): CronContext interface CronContext { records: { query( collection: string, opts?: { where?: Record<string, unknown>; limit?: number }, ): Promise<unknown[]> create(collection: string, data: Record<string, unknown>): Promise<unknown> update(collection: string, recordId: string, data: Record<string, unknown>): Promise<unknown> delete(collection: string, recordId: string): Promise<unknown> } integrations: { call(endpoint: string, params?: Record<string, unknown>): Promise<unknown> } ownerUserId: string } ``` `roomId` defaults to `'default'`. Pass `app:${env.DEEPSPACE_APP_ID}` to target the per-app RecordRoom - the scaffold convention. Scope ids key to the immutable app id - never the app name ([why](/concepts/data-model#scopes)). The `records.*` methods return `unknown` because the collection shape belongs to the app. Narrow or validate the result at the call site. They surface the RecordRoom tools API's `data` field directly. At runtime: * `query` resolves to an array of record envelopes (`{ recordId, data, createdAt, updatedAt, ... }`). * `create` / `update` resolve to `{ recordId, record }` (the envelope of the row that was written). * `delete` resolves to a delete confirmation payload. There is **no `records.get` method** on `CronContext` - use `records.query` with a `where` clause when you need a single row. **Shapes differ from server actions.** `ctx.records.query` returns the unwrapped array directly (no `ActionResult` envelope). The methods throw on failure rather than returning a `{ success: false }` envelope - wrap in `try/catch` if you need to handle denied writes inline. ## Properties * `ctx.records.*` - RBAC-bypassing record operations (results already unwrapped from the `ActionResult` envelope). * `ctx.integrations.call(endpoint, params)` - proxies through the api-worker as the app owner (signed with `APP_OWNER_JWT`), billed to the app owner. Returns the unwrapped `data` field; throws on non-2xx or `success: false`. * `ctx.ownerUserId` - convenience accessor for the owner's user ID. ## Pattern ```ts // src/cron.ts import type { CronTask } from 'deepspace/worker' import { buildCronContext } from 'deepspace/worker' export const tasks: CronTask[] = [ { name: 'heartbeat', intervalMinutes: 1 }, { name: 'daily-digest', schedule: '0 9 * * *', timezone: 'America/New_York' }, ] export async function runTask(name: string, env: Env): Promise<void> { const ctx = buildCronContext(env, env.OWNER_USER_ID, `app:${env.DEEPSPACE_APP_ID}`) if (name === 'heartbeat') { const settings = await ctx.records.query('settings', { where: { key: 'lastHeartbeat' } }) if (settings.length > 0) { await ctx.records.update('settings', settings[0].recordId, { value: new Date().toISOString(), }) } } } ``` ## Worker wiring ```ts // worker.ts export class AppCronRoom extends CronRoom<Env> { constructor(state: DurableObjectState, env: Env) { super(state, env, { tasks: cronTasks }) } protected async onTask(name: string): Promise<void> { await runCronTask(name, this.env) } } ``` Don't edit the WebSocket route or DO binding wiring - add tasks in `src/cron.ts` and the DO picks them up at construction. ## Outbound calls Use `ctx.integrations.call(...)` for third-party APIs (billed to the owner): ```ts const data = await ctx.integrations.call('resend/send-email', { to: user.data.email, subject: 'Your digest', text: '...', }) ``` For autonomous LLM calls via the AI SDK, use `createDeepSpaceAI` without `authToken`: ```ts import { createDeepSpaceAI } from 'deepspace/worker' import { generateText } from 'ai' const ai = createDeepSpaceAI(env, 'anthropic') // owner pays const { text } = await generateText({ model: ai('claude-haiku-4-5'), prompt: '...' }) ``` ## See also * [Scheduled jobs guide](/guides/scheduled-jobs) - patterns and worked examples * [Rooms reference](/sdk-reference/worker/rooms#cronroom-e) - class signature * [Real-time reference](/sdk-reference/client/realtime#usecronmonitor-roomid) - client-side monitor Source: /sdk-reference/worker/cron.md --- # Worker auth reference JWT verification, Better Auth integration, and HMAC primitives. ```ts import { verifyJwt, decodeJwtPayload, createDeepSpaceAuth, verifyInternalSignature, buildInternalPayload, signInternalPayload, computeHmacHex, timingSafeEqualHex, DEFAULT_MAX_SKEW_MS, } from 'deepspace/worker' import type { JwtVerifierConfig, VerifyOutcome, VerifyResult, VerifiedAuth, JwtClaims, TokenDebugInfo, InternalSignature, DeepSpaceAuth, DeepSpaceAuthConfig, } from 'deepspace/worker' ``` ## `verifyJwt(config, token)` Verifies a JWT against a public key. **Does not throw** - returns a `{ result, error?, debug? }` envelope. ```ts function verifyJwt( config: JwtVerifierConfig, token: string | null | undefined, ): Promise<VerifyOutcome> interface JwtVerifierConfig { publicKey: string // PEM-encoded ES256; env.AUTH_JWT_PUBLIC_KEY issuer: string // env.AUTH_JWT_ISSUER audience?: string | string[] // single value or list authorizedParties?: string[] // azp patterns; supports "*" wildcards clockSkewMs?: number // default 5000 } interface VerifyOutcome { result: VerifyResult | null // null on failure debug?: TokenDebugInfo // unverified claims, for logging error?: unknown // underlying error (not stringified) } interface VerifyResult extends VerifiedAuth {} interface VerifiedAuth { userId: string // claims.sub, surfaced for convenience claims: JwtClaims // full verified payload } interface JwtClaims { sub: string iss?: string aud?: string | string[] azp?: string exp?: number iat?: number name?: string email?: string image?: string [key: string]: unknown } ``` `VerifyResult` is **not** a flat JWT-claims object - it's a `{ userId, claims }` envelope. Read `result.userId` for the subject, and `result.claims.email` / `result.claims.name` / `result.claims.image` for the optional profile fields: ```ts app.get('/api/me', async (c) => { const auth = c.req.header('Authorization') ?? '' const token = auth.replace(/^Bearer\s+/i, '') const { result } = await verifyJwt({ publicKey: c.env.AUTH_JWT_PUBLIC_KEY, issuer: c.env.AUTH_JWT_ISSUER, }, token) if (!result) return c.json({ error: 'unauthorized' }, 401) return c.json({ userId: result.userId, email: result.claims.email, }) }) ``` ## `decodeJwtPayload(token)` Base64url-decode the JWT payload **without verification**. Useful for inspecting `iss` / `aud` / `azp` / `exp` for logging or debugging where verification has already happened upstream. ```ts function decodeJwtPayload(token: string | null | undefined): TokenDebugInfo | undefined interface TokenDebugInfo { iss?: string | null aud?: string | string[] | null azp?: string | null exp?: number | null iat?: number | null } ``` Returns `undefined` if the token is missing or malformed. Never use as a substitute for `verifyJwt` on the trust boundary. Decoded but unverified claims can be spoofed by anyone, and `TokenDebugInfo` deliberately omits `sub` to discourage that. ## HMAC primitives - internal signing For platform → app internal calls and any custom signed-request flow you build yourself. (Cron's `ctx.integrations.call` no longer uses these - it signs with `APP_OWNER_JWT` instead.) All of these are async and use `crypto.subtle` on the Workers runtime (with a Node fallback for testing). ```ts function buildInternalPayload(body: unknown): string function signInternalPayload(input: { secret: string payload: string timestamp?: string // defaults to Date.now().toString() }): Promise<InternalSignature> function verifyInternalSignature(input: { secret: string | undefined timestamp: string | null | undefined signature: string | null | undefined payload: string maxSkewMs?: number // defaults to DEFAULT_MAX_SKEW_MS }): Promise<boolean> function computeHmacHex(secret: string, data: string): Promise<string> function timingSafeEqualHex(a: string, b: string): Promise<boolean> interface InternalSignature { timestamp: string signature: string } const DEFAULT_MAX_SKEW_MS: number // 5 * 60_000 - verification window ``` `buildInternalPayload` returns a **plain string** (JSON-stringified body, or the input string passed through). The timestamp lives on the signature object returned by `signInternalPayload`, not on the payload. These are exported so apps can verify inbound internal calls (e.g., custom webhook endpoints from the platform) using the same HMAC contract the rest of the SDK uses. Most apps never call these directly. ## `createDeepSpaceAuth(config)` Construct a Better Auth instance pre-wired for DeepSpace conventions (cookie names, JWT issuance, plugin set). The scaffold doesn't build its own auth surface - it proxies to the platform auth-worker - so you only reach for this when standing up a custom auth-worker variant. ```ts function createDeepSpaceAuth(config: DeepSpaceAuthConfig): DeepSpaceAuth interface DeepSpaceAuthConfig { /** D1 database binding */ database: D1Database /** Base URL for the auth worker (e.g. "https://auth.deep.space") */ baseURL: string /** Secret for session signing */ secret: string /** Google OAuth credentials (optional) */ google?: { clientId: string; clientSecret: string } /** GitHub OAuth credentials (optional) */ github?: { clientId: string; clientSecret: string } /** Enable email/password authentication (default: true) */ emailAndPassword?: boolean /** Trusted origins for CORS */ trustedOrigins?: string[] } type DeepSpaceAuth = ReturnType<typeof createDeepSpaceAuth> ``` `DeepSpaceAuth` is the `better-auth` `Auth` instance with the DeepSpace defaults (organization + twoFactor plugins, `*.deep.space` / `*.app.space` / `localhost:*` trusted origins) applied. There are no `privateKey` / `publicKey` / `issuer` fields on the config - JWT signing keys live in the auth-worker's environment, not in this object. ## See also * [Authentication guide](/guides/authentication) * [Client auth reference](/sdk-reference/client/auth) * [Architecture concepts](/concepts/architecture#security-model-websocket-identity) Source: /sdk-reference/worker/auth.md --- # Bindings reference Migration runner, per-tenant metering, and binding manifest exports. ```ts import { runMigrations, meterAi, meterVectorize, meterUsage, COST_RATES, AUTO_PROVISION_SENTINEL, AUTO_PROVISIONABLE_TYPES, ALLOWED_BINDING_TYPES, RESERVED_BINDING_NAMES, validateBindingManifest, isAutoProvision, bindingManifestFromOutputConfig, } from 'deepspace/worker' import { captureScreenshot } from 'deepspace/server' import type { CustomBinding, CustomBindingManifest, ValidationError, RunMigrationsResult, } from 'deepspace/worker' import type { ScreenshotEnv, ScreenshotOptions, ScreenshotResult, } from 'deepspace/server' ``` ## `runMigrations(db, migrations)` Idempotently apply a list of SQL migrations to a D1 database. Designed to run at worker startup. ```ts function runMigrations( db: D1Database, migrations: readonly string[], ): Promise<RunMigrationsResult> interface RunMigrationsResult { fromVersion: number toVersion: number applied: number } ``` State is tracked in a meta-table `_dpc_migrations(idx INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)`. A meta-table is used instead of `PRAGMA user_version` because D1's SQLite authorizer rejects PRAGMA writes with `SQLITE_AUTH`, even though the same statements work in raw SQLite. **Contract:** * Each array entry is one migration; index in the array is its sequence number. * Each migration string can contain multiple `;`-separated statements. * **Don't put `;` inside string literals** - the split is naive. * Statements run via `db.prepare(sql).run()`, not `exec()`. * Idempotent - re-running with the same array is a no-op. * **Append new migrations to the end; never reorder or delete entries.** * Throws on any individual migration failure; the failed row is not inserted, so the next deploy retries. * **Run it in a controlled initialization path and avoid concurrent callers.** D1 serializes statements per database, but two simultaneous `runMigrations` calls can race the same migration index - the duplicate meta-table INSERT collides on the primary key and the losing caller sees the failure. Worker startup is single-threaded per isolate, so a before-first-DB-use call is safe; the cross-isolate race is rare and self-healing (migrations should be `IF NOT EXISTS`-style), but don't invoke it from arbitrary request handlers in parallel. ```ts await runMigrations(env.MY_DB, [ `CREATE TABLE notes ( id TEXT PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_notes_created ON notes(created_at);`, `ALTER TABLE notes ADD COLUMN tags TEXT;`, ]) ``` ## Per-tenant metering Every deployed app gets a `USAGE_EVENTS` Analytics Engine binding automatically - don't declare it in `wrangler.toml`. The metering helpers write usage events keyed by `OWNER_USER_ID`. All three helpers take a `MeteringEnv` shape - `{ USAGE_EVENTS?, OWNER_USER_ID?, APP_NAME? }` - which the app's `Env` already satisfies because the deploy worker injects all three. The runtime `MeteringEnv` is intentionally weaker than `Env` so the helpers compile in shared utility code. ### `meterAi(env, model, fields)` ```ts function meterAi( env: MeteringEnv, model: string, fields: { inputChars?: number; outputChars?: number; calls?: number }, ): boolean ``` Emits `op='input'` and `op='output'` events. If both are 0, emits `op='call'` so the model invocation is still recorded. ```ts const result = await env.AI.run('@cf/meta/llama-3.1-8b', { messages }) meterAi(env, '@cf/meta/llama-3.1-8b', { inputChars: JSON.stringify(messages).length, outputChars: result.response?.length ?? 0, }) ``` ### `meterVectorize(env, indexName, op, fields)` ```ts function meterVectorize( env: MeteringEnv, indexName: string, op: 'query' | 'upsert' | 'delete' | 'getByIds', fields: { vectors?: number; dims?: number; storedCount?: number }, ): boolean ``` Units calculation: * **`query`**: `(vectors + storedCount) * dims` (matches CF's `(stored + queries) * dims` formula) * **`upsert` / `delete` / `getByIds`**: `vectors * dims` Pass `storedCount` on queries against non-empty indexes or you'll significantly undercount. ```ts const matches = await env.VEC.query(embedding, { topK: 10 }) meterVectorize(env, 'docs', 'query', { vectors: 1, dims: 768, storedCount: await env.VEC.describe().then(d => d.vectorsCount), }) ``` ### `meterUsage(env, kind, fields)` Generic fallback for any other binding (Browser Rendering, Hyperdrive, custom kinds). ```ts function meterUsage( env: MeteringEnv, kind: string, fields: { id?: string; op?: string; units?: number; count?: number }, ): boolean ``` ```ts const pdf = await env.BROWSER.fetch(url).then(r => r.blob()) meterUsage(env, 'browser', { id: 'render', units: 1, count: 1 }) ``` ### Behavior All three helpers return `boolean` - `false` when `USAGE_EVENTS` is missing (local dev) or when Analytics Engine throws. **Metering never breaks the calling code path** - wrap in `void` if you prefer to ignore the return. ### `COST_RATES` Per-`units` USD multipliers for dashboard rollup. Multiply `SUM(_sample_interval * doubles[1])` by the matching rate to get USD without re-querying Cloudflare's billing API. ```ts const COST_RATES = { ai: { /** USD per character (input or output). */ perChar: number, }, vectorize: { /** USD per queried dimension. */ queriedPerDim: number, /** USD per stored dimension per month. */ storedPerDimPerMonth: number, }, } as const ``` The shape is a nested object grouped by binding kind, not a flat record of dotted keys. ## Shared Browser Rendering (`captureScreenshot`) Render a URL to a PNG via the platform's shared Browser Rendering binding, without declaring your own `[browser]` binding in `wrangler.toml`. ```ts function captureScreenshot( env: ScreenshotEnv, opts: ScreenshotOptions, ): Promise<ScreenshotResult | null> interface ScreenshotOptions { url: string viewport?: { width: number; height: number } waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2' timeoutMs?: number fullPage?: boolean } interface ScreenshotEnv extends PlatformWorkerEnv { APP_NAME: string APP_IDENTITY_TOKEN: string } interface ScreenshotResult { /** PNG bytes. */ body: ArrayBuffer /** `image/png`. */ contentType: string } ``` The helper POSTs to the platform-worker's internal screenshot endpoint, signed with `APP_IDENTITY_TOKEN` and `APP_NAME` (the same HMAC-of-app-name pattern `/internal/files` uses). The platform enforces a host allowlist (`*.app.space`, `*.deep.space`), per-app sliding rate limits, and viewport / timeout clamping. Imported from `'deepspace/server'` - this is a server-side helper, not a browser export. ### Example: OG-image route The scaffold's `worker.ts` already binds `APP_IDENTITY_TOKEN`, `APP_NAME`, and the `PLATFORM_WORKER` service binding, so the route below is copy-paste-runnable - no `wrangler.toml` edits required. ```ts import { captureScreenshot } from 'deepspace/server' app.get('/api/og/:roomId', async (c) => { const roomId = c.req.param('roomId') const shot = await captureScreenshot(c.env, { url: `https://${c.env.APP_NAME}.app.space/rooms/${roomId}/preview`, viewport: { width: 1200, height: 630 }, waitUntil: 'networkidle0', timeoutMs: 8000, }) if (!shot) { // Allowlist miss, rate limit, timeout, or local dev without a token. // Redirect to a static placeholder so the caller still gets an image. return c.redirect('/og-placeholder.png', 302) } return new Response(shot.body, { headers: { 'content-type': shot.contentType, 'cache-control': 'public, max-age=86400', }, }) }) ``` ### The `null` return is the contract `captureScreenshot` returns `null` on **any** non-2xx from the platform - rate limit, allowlist miss, target timeout, BR binding misconfigured platform-side. Underlying errors are logged on the platform side; the caller just sees `null`. Always branch on it and fall back to a placeholder. Users will hit this path during local dev (see below) and on the first request after an allowlist change. `APP_IDENTITY_TOKEN` is only populated after your first `npx deepspace deploy`. Calls before that, or from a fresh `deepspace dev start` session with no prior deploy, will return `null`. Test screenshot flows against a deployed environment, or guard with a placeholder during local bring-up. ### When you still want your own `browser_rendering` binding `captureScreenshot` covers standard preview / OG-image flows on app-owned hosts. Declare your own `[browser]` binding in `wrangler.toml` only if you need one of: * **Unmetered Browser Rendering** - the shared binding counts against the platform's per-app sliding rate limit; an app-owned binding is billed and rate-limited on your CF account. * **Custom user agents, headers, or cookies** - the shared endpoint sets these platform-side and does not accept overrides. * **Third-party hosts** - URLs outside `*.app.space` / `*.deep.space` are rejected by the allowlist. Render them via your own binding. * **Direct Puppeteer scripting** (`page.evaluate`, multi-step navigation, PDF export) - the shared endpoint exposes a single capture call only. For those cases, add `[browser] binding = "BROWSER"` to `wrangler.toml` and call `env.BROWSER.fetch(...)` directly - see [custom bindings](/guides/custom-bindings). ## Manifest validation For tooling that introspects or generates binding manifests. ```ts const AUTO_PROVISION_SENTINEL: 'auto' const AUTO_PROVISIONABLE_TYPES: Set<string> // 'd1', 'kv_namespace', 'vectorize', 'r2_bucket', 'queue' const ALLOWED_BINDING_TYPES: Set<string> // 'vectorize', 'ai', 'r2_bucket', 'kv_namespace', // 'd1', 'queue', 'browser_rendering', 'analytics_engine', 'hyperdrive' const RESERVED_BINDING_NAMES: Set<string> // ASSETS, PLATFORM_WORKER, API_WORKER, APP_NAME, OWNER_USER_ID, // AUTH_JWT_PUBLIC_KEY, AUTH_JWT_ISSUER, AUTH_WORKER_URL, // APP_IDENTITY_TOKEN, APP_OWNER_JWT, USAGE_EVENTS function validateBindingManifest( manifest: unknown, ): | { valid: true; bindings: CustomBindingManifest } | { valid: false; errors: ValidationError[] } function isAutoProvision(b: CustomBinding): boolean function bindingManifestFromOutputConfig( outputConfig: Record<string, unknown>, ): CustomBindingManifest interface ValidationError { /** Undefined for top-level shape failures (e.g. manifest is not an array). */ binding?: CustomBinding reason: string } ``` `validateBindingManifest` returns a discriminated union: on success, `{ valid: true, bindings }`; on failure, `{ valid: false, errors }` carrying one or more `ValidationError`s. The three name/type sets are exported as `Set<string>` instances, not tuples - use `.has(...)` rather than indexing. ## `CustomBinding` (wire type) ```ts type CustomBinding = | { type: 'vectorize' name: string /** Either a pre-existing index name or the literal `"auto"`. */ index_name: string /** Required when `index_name === "auto"`. */ dimensions?: number /** Required when `index_name === "auto"`. */ metric?: 'cosine' | 'euclidean' | 'dot-product' } | { type: 'ai'; name: string } | { type: 'r2_bucket' name: string /** Either a pre-existing bucket name or the literal `"auto"`. */ bucket_name: string } | { type: 'kv_namespace' name: string /** Either a pre-existing KV namespace ID or the literal `"auto"`. */ namespace_id: string /** Required when `namespace_id === "auto"`. Human-readable title. */ title?: string } | { type: 'd1' name: string /** Either a pre-existing D1 database UUID or the literal `"auto"`. */ id: string /** Required when `id === "auto"`. Human-readable database name. */ database_name?: string } | { type: 'queue' name: string /** Either a pre-existing queue name or the literal `"auto"`. */ queue_name: string } | { type: 'browser_rendering'; name: string } | { type: 'analytics_engine'; name: string; dataset?: string } | { type: 'hyperdrive'; name: string; id: string } type CustomBindingManifest = CustomBinding[] ``` The deploy worker validates inbound manifests against this shape before forwarding to Workers for Platforms. The exact ID field varies by binding kind - `kv_namespace` uses `namespace_id`, `d1` uses `id`, `queue` uses `queue_name`, `r2_bucket` uses `bucket_name`, and `vectorize` uses `index_name`. `analytics_engine.dataset` is optional. ## See also * [Custom bindings guide](/guides/custom-bindings) - declaring resources in `wrangler.toml` * [Deployment concepts](/concepts/deployment) - auto-provisioning lifecycle Source: /sdk-reference/worker/bindings.md --- # Proxy helpers reference Helpers for fetching the auth, API, and platform workers. The scaffolded `worker.ts` already uses these for every cross-worker call. They prefer service bindings (set up automatically on deploy) and fall back to HTTPS URLs in local dev. ```ts import { apiWorkerFetch, platformWorkerFetch, authWorkerFetch, resolveApiTransport, createScopedR2Handler, } from 'deepspace/worker' import type { ApiWorkerEnv, PlatformWorkerEnv, AuthWorkerEnv, ScopedR2Config, ScopedR2Handler, ScopedR2Auth, ScopeContext, PrefixResult, } from 'deepspace/worker' ``` **Do not replace these with raw `c.env.X.fetch(...)`.** `wrangler dev` doesn't surface service bindings cross-process for SDK apps - the binding is `undefined` locally and the raw fetch silently fails. The helpers paper over the dev/prod mismatch. ## `apiWorkerFetch(env, path, init?)` Fetch the api-worker (Stripe, integrations, profiles, OAuth, usage tracking). ```ts function apiWorkerFetch( env: ApiWorkerEnv, path: string, init?: RequestInit, ): Promise<Response> interface ApiWorkerEnv { API_WORKER?: Fetcher // service binding (preferred in prod) API_WORKER_URL?: string // HTTPS fallback (used in dev) } ``` Prefers the `API_WORKER` service binding; falls back to `API_WORKER_URL`. Throws an actionable Error if neither is configured. ## `platformWorkerFetch(env, pathOrRequest, init?)` Fetch the platform-worker for platform HTTP services such as the scoped R2 file gateway and screenshot proxy. ```ts function platformWorkerFetch( env: PlatformWorkerEnv, pathOrRequest: string | Request, init?: RequestInit, ): Promise<Response> interface PlatformWorkerEnv { PLATFORM_WORKER?: Fetcher PLATFORM_WORKER_URL?: string } ``` Accepts either a path string or a full `Request`, so file routes can hand off `c.req.raw` derivatives with method, headers, and body intact: ```ts app.all('/api/files/*', async (c) => { return platformWorkerFetch(c.env, c.req.raw) }) ``` For PNG capture against an `*.app.space` / `*.deep.space` URL, prefer the higher-level [`captureScreenshot`](/sdk-reference/worker/bindings#shared-browser-rendering-capturescreenshot) wrapper instead of calling `platformWorkerFetch` against `/internal/screenshot` yourself - it owns the HMAC headers and the `null`-on-failure contract. ## `authWorkerFetch(env, path, init?)` Fetch the auth-worker (Better Auth, JWT issuance, OAuth flows). ```ts function authWorkerFetch( env: AuthWorkerEnv, path: string, init?: RequestInit, ): Promise<Response> interface AuthWorkerEnv { /** * HTTPS URL for the auth-worker. Optional on the type so apps can declare * a partial env shape during local bring-up, but the function throws if it's * unset at call time. */ AUTH_WORKER_URL?: string } ``` URL-only by design - the auth-worker has no service binding, which keeps `Set-Cookie` headers verbatim over HTTPS. Set `AUTH_WORKER_URL` to the auth-worker **origin** (for example, `https://auth.deep.space`), then pass the request path once (`/api/auth/ok`, not a second auth origin or a duplicated prefix). Absolute request URLs are reduced to their path and query before being applied to the configured origin. Throws if `AUTH_WORKER_URL` is missing. ## `resolveApiTransport(env)` Exported for the AI helper, which needs the URL form to rewrite an internal `https://api-worker.internal` placeholder before calling provider SDKs. Most apps don't need this. ```ts function resolveApiTransport(env: ApiWorkerEnv): | { kind: 'binding'; fetcher: Fetcher } | { kind: 'url'; baseUrl: string } ``` ## Production note Cross-worker calls over plain `*.workers.dev` URLs return Cloudflare error 1042 in production. The service binding is the only working transport for deployed apps; the URL fallback is a dev-only convenience the CLI writes into `.dev.vars`. If a deployed app needs `apiWorkerFetch` or `platformWorkerFetch`, the corresponding `[[services]]` binding must be in `wrangler.toml`: ```toml [[services]] binding = "API_WORKER" service = "deepspace-api" [[services]] binding = "PLATFORM_WORKER" service = "deepspace-platform" ``` ## R2 helpers ### `createScopedR2Handler(config)` Factory that builds a route handler for prefix-scoped R2 reads/writes. The scaffold uses this implicitly via the `useR2Files` client hook - most apps don't call it directly. ```ts function createScopedR2Handler(config: ScopedR2Config): ScopedR2Handler interface ScopedR2Config { /** * Resolve the R2 key prefix for the given scope. * Called with the `?scope=` query param value (default: 'self'). */ resolvePrefix(scope: string, ctx: ScopeContext): PrefixResult /** Require a non-null userId for upload and delete. Default: true. */ requireAuthForMutations?: boolean } interface ScopeContext { userId: string | null url: URL } type PrefixResult = | { prefix: string; error?: undefined } | { prefix?: undefined; error: string } type ScopedR2Handler = ( request: Request, url: URL, bucket: R2Bucket, auth: { userId: string | null }, ) => Promise<Response> ``` Security guarantees: download/delete keys are validated against the resolved prefix, path traversal (`..`) is rejected at the entry point, and mutations require a non-null userId by default. ## See also * [Architecture concepts](/concepts/architecture) - how app and platform responsibilities are separated * [File uploads guide](/guides/file-uploads) Source: /sdk-reference/worker/proxy-helpers.md --- # Testing reference The Playwright fixture and account helpers from `deepspace/testing`. ```ts import { test, expect, loadAllTestAccounts, pickTestAccounts, findTestAccountByName, ensureStorageState, newSignedInContext, getStatePathForEmail, } from 'deepspace/testing' import type { MultiplayerUser, UsersFixture, TestAccount, EnsureStorageStateOptions, } from 'deepspace/testing' ``` Imported only inside Playwright spec files. ## `test` and `expect` Re-exports from Playwright with the `users` fixture pre-installed: ```ts import { test, expect } from 'deepspace/testing' test('A sends, B sees', async ({ users }) => { const [alice, bob] = await users(2) // ... }) ``` The fixture caches `storageState` per account and app origin, so each test account signs in once per target app - not once per test. A cached file is validated against the app before reuse (a session probe plus an identity match, once per worker process); a missing, dead, or wrong-account state triggers one fresh sign-in that overwrites it, so a stale cache can never silently sign a test in as the wrong user or against the wrong origin. **Requires `baseURL`** in `tests/playwright.config.ts`. The scaffold sets this; tests error with `users fixture requires a baseURL` if it's missing. ## `users(N | string[], options?)` - the fixture ```ts type UsersFixture = ( selector: number | string[], options?: { label?: string }, ) => Promise<MultiplayerUser[]> interface MultiplayerUser { context: BrowserContext page: Page email: string name: string /** Test account user ID, if known from the accounts registry. */ userId?: string } ``` ```ts // First N accounts by createdAt const [a, b] = await users(2) // Specific accounts by name const [alice, bob] = await users(['Alice', 'Bob']) // First N filtered by label const [team] = await users(1, { label: 'team-fixture' }) ``` Contexts auto-close when the test finishes - no manual cleanup needed for contexts. You still need to clean up records you create during the test (see [Testing guide → Test data cleanup](/guides/testing#test-data-cleanup)). ## Escape hatches When the fixture is too high-level, import the underlying helpers directly: | Helper | Signature | | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `loadAllTestAccounts()` | `() => TestAccount[]` - every cached account (sync) | | `pickTestAccounts(n, opts?)` | `(n: number, opts?: { label?: string }) => TestAccount[]` (sync); throws if not enough accounts | | `findTestAccountByName(name)` | `(name: string) => TestAccount` (sync); throws if not found | | `ensureStorageState(browser, account, baseURL, options?)` | Sign in once and return the cached `storageState` path | | `newSignedInContext(browser, account, baseURL, options?)` | One-liner for a signed-in `BrowserContext` | | `getStatePathForEmail(email, baseURL)` | Direct cache-path lookup, keyed by account and app origin | All three account loaders read from `~/.deepspace/test-accounts.json` synchronously - they don't return promises. ### `TestAccount` ```ts interface TestAccount { email: string password: string name?: string label?: string | null id?: string userId?: string createdAt?: number } ``` Loaded from `~/.deepspace/test-accounts.json`, populated by `npx deepspace test accounts create`. `createdAt` is a numeric epoch millisecond timestamp, not an ISO string. ### `EnsureStorageStateOptions` ```ts interface EnsureStorageStateOptions { /** Force a fresh sign-in even if a cached state validates. */ force?: boolean } ``` Reuse is gated by validation, not by file age - there is no `maxAgeMs`. A cached state is reused only when it still authenticates as the right account against the target origin; otherwise it is refreshed regardless of how recently it was written. ### `ensureStorageState` / `newSignedInContext` signatures ```ts function ensureStorageState( browser: Browser, account: { email: string; password: string }, baseURL: string, options?: EnsureStorageStateOptions, ): Promise<string> function newSignedInContext( browser: Browser, account: { email: string; password: string }, baseURL: string, options?: EnsureStorageStateOptions, ): Promise<BrowserContext> ``` `browser` is always the first argument, then the account record, then `baseURL`, then options. ## Patterns ### Multiplayer test ```ts import { test, expect } from 'deepspace/testing' test('shared state syncs', async ({ users }) => { const [a, b] = await users(2) await a.page.goto('/board') await b.page.goto('/board') await a.page.getByTestId('add-card-btn').click() await a.page.getByTestId('card-title-input').fill('Hello') await a.page.getByTestId('save-card-btn').click() await expect(b.page.getByText('Hello')).toBeVisible() }) ``` ### Reusing a signed-in context outside the fixture ```ts import { test } from '@playwright/test' import { ensureStorageState, loadAllTestAccounts } from 'deepspace/testing' test('custom flow', async ({ browser }) => { const accounts = loadAllTestAccounts() const statePath = await ensureStorageState(browser, accounts[0], 'http://localhost:5173') const context = await browser.newContext({ storageState: statePath }) // ... use context.newPage(), etc. await context.close() }) ``` ## See also * [Testing guide](/guides/testing) - full workflow, test extension checklist, debugging * [CLI test command](/cli-reference/commands#test) - runner flags * [CLI `test accounts` command](/cli-reference/commands#test-accounts) - provisioning the account pool Source: /sdk-reference/testing.md --- # CLI overview The shape of the CLI: authentication, exit codes, the JSON action contract, agents and CI. The CLI ships inside the `deepspace` package, which is added to every scaffolded app. You run it via `npx`: ```bash npx deepspace <command> [options] ``` No global install is needed. To create a new app, use `npm create deepspace@latest` - it fetches the latest scaffolder on demand. ## The shape of the CLI The surface is **noun-verb**. Related commands live under a noun, and the noun on its own just prints its subcommands: ```bash npx deepspace dev # prints the subcommands - does NOT start a server npx deepspace dev start # starts the server ``` | Group | Purpose | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `auth` | `login`, `logout`, `whoami` - one session for every app on the machine | | `app` | The app as a platform object: `create`, `init`, `list`, `files`, `source`, `update`, `undeploy`, `transfer`, `collaborators`, `domain`, `usage` | | `dev` | `start`, `kill` - the local dev server | | `test` | `run`, `screenshot`, `accounts` | | `secrets` | `list`, `set`, `get`, `delete`, `upload`, `download`, `pull`, `configs` | | `workspace` | `new`, `attach`, `sync`, `list`, `status`, `land`, `drop` - parallel-agent workspaces | | `integrations` | `list`, `info`, `invoke` | And at the top level: `status`, `activity`, `logs`, `push`, `pull`, `clone`, `releases`, `rollback`, `deploy`, `add`, `feedback`. [Full command reference →](/cli-reference/commands) Typed an older command name? The CLI points you at where it moved: `deepspace login` prints ``did you mean `deepspace auth login`?`` ## Login state `npx deepspace auth login` opens a browser and signs you in with **GitHub or Google**. DeepSpace accounts are OAuth-only: there is no password to choose, and public email signup is closed. You log in **once per machine**. The session is shared across every app and covers `dev`, `test`, `deploy`, and billed integration calls. ```bash npx deepspace auth whoami # human-readable npx deepspace auth whoami --json # machine-readable ``` Login writes two files under `~/.deepspace/`, both mode `0600`: * `~/.deepspace/session` - the long-lived session token. This is the credential. * `~/.deepspace/token` - a cached short-lived JWT, re-minted from the session automatically by every other command. Treat both as secret - never commit them. Credentials are stored **per auth plane**. Production keeps the plain `session` / `token` names; a non-production `DEEPSPACE_ENV` or `DEEPSPACE_AUTH_URL` gets its own suffixed pair (for example `~/.deepspace/session.auth-deepspacesites-com`), so a staging login cannot clobber your production session. The flip side: `DEEPSPACE_ENV` and `DEEPSPACE_AUTH_URL` **select which stored credential a command reads**. A shell with `DEEPSPACE_ENV=staging` set is logged out on staging even when `~/.deepspace/session` holds a perfectly good production login. When no usable session exists for the selected plane, every command refuses `not_authenticated` (exit 1) with a sentence that names the plane it selected and what selected it - `Not logged in on staging (selected by DEEPSPACE_ENV=staging)`, or `… on the auth service at <url> (selected by DEEPSPACE_AUTH_URL)` - and, when another plane does hold a session, says so and how to select it (`You are signed in on production - select that plane (unset DEEPSPACE_ENV), or log in here`). The same sentence spells out the headless login form up front (`auth login --email you@example.com --password-stdin`, or `DEEPSPACE_EMAIL` / `DEEPSPACE_PASSWORD`), because the bare `auth login` action it ships refuses `interactive_required` without a TTY. Read the plane before running the action: a wrong plane is fixed by unsetting a variable, not by logging in again. ## Agents and CI Agents do not get DeepSpace accounts. An agent runs **as you**, using your session. On your own machine that is already true: any agent you launch inherits `~/.deepspace/session` and every `deepspace` command it runs is attributed to you. Nothing extra to configure. For an agent in a container, or a CI job, do the same thing deliberately - log in once where a browser exists, then supply that environment with the session file: ```bash # once, on your machine npx deepspace auth login # then mount ~/.deepspace into the container docker run -v ~/.deepspace:/root/.deepspace my-agent-image ``` In CI, store the **contents** of `~/.deepspace/session` as an encrypted secret and write it to `~/.deepspace/session` before the first `deepspace` command. Copying `~/.deepspace/token` is unnecessary - it is only a cache, and the CLI re-mints it from the session. Don't mount `~/.deepspace` read-only. The JWT in `~/.deepspace/token` is short-lived, and commands rewrite it in place when it expires - a read-only mount fails on the first refresh. `~/.deepspace/session` is a long-lived credential for your account: whatever holds it can deploy as you. Scope it like a production secret. `npx deepspace auth logout` revokes the session server-side, which invalidates every copy of it. ### The password flags `deepspace auth login --help` lists `--email`, `--password`, and `--password-stdin`, and the CLI reads `$DEEPSPACE_EMAIL` / `$DEEPSPACE_PASSWORD` when stdin is non-interactive or `--json` was passed. These exist for the two kinds of account that *do* have a password: * **`@deepspace.test` test accounts** you create with [`deepspace test accounts create`](/cli-reference/commands#test-accounts) to drive multi-user auth flows in [Playwright tests](/guides/testing#provisioning-test-accounts). * **Internally provisioned accounts**, minted by SDK maintainers through an admin endpoint (the CI bot, for instance). Not available to the public. Your own DeepSpace account is neither, so these flags cannot log you in - it has no password. Use the stored session above. ### The `interactive_required` contract If you ask for `--json`, or stdout is not a TTY, and you supply no credentials, the CLI refuses immediately rather than opening a browser you cannot reach: ``` Browser login needs an interactive terminal. Use --email with --password-stdin (or $DEEPSPACE_EMAIL/$DEEPSPACE_PASSWORD). ``` The refusal carries the machine-readable code `interactive_required`. Retrying will not help. The remedy named in the message applies only to test and internally provisioned accounts. **For a real DeepSpace account the fix is upstream of the failing call:** log in once in a browser on a machine that has one, then give this environment the resulting `~/.deepspace/session`, as above. ## Agent-friendly defaults * **`--json` nearly everywhere.** Commands emit a single-line `{ ok, ... }` envelope. `logs --json` and `activity --follow --json` emit NDJSON - one frame per line - so they pipe cleanly while following. * **Non-interactive by default.** Missing required arguments produce a refusal with a machine-readable code and a `Next:` suggestion, rather than a stdin prompt. * **`--yes` skips confirmations** on `app undeploy`, `feedback`, `integrations invoke`, and `test accounts clear`. * **Refusals carry codes.** `interactive_required`, `dirty_worktree`, `push_too_large`, `not_a_collaborator`, and friends are stable identifiers - branch on the code, never on the prose. The [table below](#refusal-codes-by-command) lists them by command. `npx deepspace status` is the fastest way for an agent to orient in an unfamiliar checkout: it prints session, app identity, workspace, and the live release in one screenful, and takes `--json`. ## Exit codes Every command follows one exit contract. Human text is complete on its own; `--json` is the machine mirror of the same result, and refusals carry a stable `code`. | Exit | Meaning | | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | Operation completed. | | `1` | Failure or refusal. Retrying unchanged will not help - fix the stated cause first. | | `2` | Safe partial progress or stop, with `actionRequired: true` - the operation did what it could and a local step, or a judgment, remains. The envelope is still `ok: false` with a `code` naming the state: `release_in_progress` after a deploy that built and uploaded but did not release, or `app_not_initialized` with the `app init` action. | Branch on the exit code and the `code` field, never on the prose. Do not pre-check authentication before an operation - run the operation and let it refuse: an unauthenticated call fails with `code: "not_authenticated"` and exit 1, which is cheaper and more truthful than a separate probe. ## The `action` contract When exactly one deterministic follow-up exists, the JSON result names it: ```json {"action":{"cwd":"/absolute/app","argv":["deepspace","pull"]}} ``` Human output renders the same thing as a `Next:` line. Two rules: * **Execute `argv` directly in `cwd`.** Spawn it as an argv array; never join it into a shell string. * **Absence is not "done".** Terminal results, status reports, consent decisions, destructive overrides, and input-dependent choices deliberately omit the field. An exit-2 result without an `action` means its facts need inspecting - never infer a command from the field's absence, and never invent one. One-shot `--json` writes exactly one document. The exceptions are streams: `logs --json` is NDJSON in both snapshot and follow modes, `activity --follow --json` is NDJSON, and `dev start` / `test screenshot` inherit their child's output on stdout, so their final envelope follows the stream as the **last** line. Parse one frame per line; the [command reference](/cli-reference/commands) documents each stream's frame shapes. `test run --json` is not one of those exceptions. It routes the spawned suite's output to **stderr**, so its stdout is exactly one JSON envelope - `npx deepspace test run --json | jq` works with no last-line handling, and the live suite transcript is still visible on stderr. (`dev start --json` writes *two* envelopes on stdout: a readiness line - `{"ok":true,"ready":true,"url":...}` - the moment the port answers, and the exit envelope last.) ## Refusal codes by command The codes an agent branches on, grouped by where they come from. Codes are stable identifiers; the sentences beside them change. "Action" says whether the refusal ships an executable `action` - when it does not, the remedy is a decision, and the prose names the choices. Exit is `1` unless marked `2`. **Every command** (the shared preconditions, raised before any work): | Code | Meaning | Action | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | `not_authenticated` | No usable session for the selected plane. The sentence names the plane, what selected it, and any plane that does hold a session (above). | `auth login` - but check the plane first | | `interactive_required` | `auth login` needs a browser and there is no TTY (or `--json`); no credentials were supplied. | none - supply a session file or the password form | | `not_in_app_repo` | No `wrangler.toml` at or above the working directory (and no `--app`). `dev`/`test` also list sibling app directories when they see any. | none - `cd`, scaffold, or pass `--app` | | `app_not_initialized` (exit 2) | Inside an app whose `wrangler.toml` has no `DEEPSPACE_APP_ID` (or the scaffold's `__APP_ID__` placeholder). Raised by `deploy`, `dev start`, `test run`, `secrets`, and every command that resolves the local app (`logs`, `releases`, `push`, …) - one builder, one sentence. | `app init` | | `no_app_id_for_env` | `--env <name>` names a `[env.<name>]` block with no id of its own - each environment is its own app. | none - `app init --env <name>`, or omit `--env` | | `invalid_app_id` | `DEEPSPACE_APP_ID` is present but is not an app id (`app_` + 26 characters). Ids are server-minted, so it was hand-edited or corrupted; there is deliberately **no** `app init` action, which would mint a fresh id over the top and orphan the app. | none - restore the id (`app list` shows yours), or `app init --new-id` to fork | | `invalid_app` / `invalid_env` / `ambiguous_target` | `--app` or `--env` is blank or malformed, or both were passed. | none | | `app_not_found` | An explicit `--app <id or name>` matched nothing you can access (a never-deployed app has no name yet - target it by id). | none | | `network_error` | The platform service could not be reached (offline, DNS, refused). Names the URL and the override variable. | none - retry once connectivity is back | **Per command:** | Command | Code | Meaning | Action | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | `auth login` | `invalid_credentials` | Password login answered 401 - wrong email or password. | none | | `auth login` | `login_failed` | Any other auth-service refusal, or a session issued without a cookie or token. | none | | `app init` | `app_not_registered` | The id in `wrangler.toml` was minted locally by an older SDK and no server registered it. | `app init --new-id` | | `app init` | `not_app_owner` | The id is registered to another account (a cloned repo). Forking is a data decision. | none | | `app undeploy` | `undeploy_declined` | The interactive confirmation was declined; nothing changed. | none | | `app undeploy` | `registry_takedown_failed` (exit 2) | The script was removed but the registry did not release the route. | `app undeploy <id>` again | | `app update` | `invalid_package_manifest` / `invalid_migration_manifest` | The explicit app manifest is malformed. The read-only guide will not repair or replace it. | none | | `deploy` | `app_not_registered` | The id no server has registered (an older scaffold). | none - `app init --new-id` | | `deploy` | `forbidden` | The app belongs to another account; names the app and the signed-in account. | none - be added as a collaborator, or `app init --new-id` | | `deploy` | `deploy_in_progress` | Another deploy of **this directory** holds `.deepspace/deploy.lock` (`holder.pid`, `startedAt`). | none - wait; remove the lock only if no deploy is running | | `deploy` | `release_in_progress` (exit 2) | Another deploy of this app, from another checkout, is between prepared and live. This run built and uploaded but did not release. | the same deploy, again | | `deploy` | `secrets_config_missing` | The selected secrets config does not exist. | `secrets configs create <name>` | | `deploy` | `app_id_env_mismatch` / `app_id_define_unsubstituted` | The built client bundle carries another app's id, or the unreplaced `__DEEPSPACE_APP_ID__` define. The message is the three-file retrofit; `app update` reports the matching guidance without editing it. | none | | `deploy` | `dirty_worktree`, `behind_trunk`, `stale_base`, `workspace_unsynced`, `rename_required`, `owner_jwt_missing` | Lineage and identity guards - see [the refusal taxonomy](/guides/releases-and-rollback#the-refusal-taxonomy). | varies | | `push` / `pull` / `deploy` | `merge_in_progress` | The worktree is mid-merge, -rebase, -cherry-pick, or -revert; `HEAD` is the pre-operation commit. | none - `--continue` or `--abort` | | `push` / `pull` / `clone` / the workspace verbs | `source_managed_by_github` | The app's source of record is GitHub; use Git. | none | | `push` | `no_commits` (exit 2 on an uninitialized scaffold), `unknown_branch`, `push_too_large` | See [push](/cli-reference/commands#push). | `app init` on a scaffold; else none | | `logs` | `app_not_deployed` | The app has never been deployed, so there are no logs to read. | `deploy` from the app's checkout | | `rollback` | `no_bundle`, `do_class_verify_failed`, `do_class_deletion` | See [releases and rollback](/guides/releases-and-rollback). | none | | `secrets upload` | `file_not_found` | The path does not exist (`-` reads stdin). | none | | `test run` | `unknown_suite` | Not a suite name, a `.spec.ts` path, or `--grep`. | none | | `integrations invoke` | `cost_confirmation_required` | A paid call outside an interactive terminal (or under `--json`) without `--yes`. No call was made. | none - re-run with `--yes` | | `integrations invoke` / `info` | `unknown_integration`, `unknown_endpoint` | The target does not exist in the catalog. | `integrations list` | | `app collaborators` | `not_a_collaborator`, `owner_already_authorized` | See [collaborators](/guides/collaborators). | none | | `app transfer offer` | `user_not_found` | The recipient has never signed in to DeepSpace. | none | ## Local dev workflow Scaffold ```bash npm create deepspace@latest my-app cd my-app ``` Sign in ```bash npx deepspace auth login ``` One session covers every app on the machine. Develop ```bash npx deepspace dev start ``` Vite + worker run on `localhost:5173` with HMR. Test ```bash npx deepspace test run ``` Smoke + API specs run against the dev workers. Commit, then deploy ```bash git commit -am "my changes" npx deepspace deploy ``` A deploy records the commit it ships, so the worktree must be clean. ## Cleaning up leaked processes If a previous `dev` session crashed, leaked `workerd` / `wrangler` processes can hold ports: ```bash npx deepspace dev kill # default port 5173 npx deepspace dev kill --port 5180 npx deepspace dev kill --all # sweep every workerd/wrangler ``` **Don't kill a parallel session's processes.** If a sibling session is running on port 5173, use `--port 5174` (and update `tests/playwright.config.ts` to match) instead of `--all`. ## Next steps * [Command reference](/cli-reference/commands) - every command, every flag. * [Quickstart](/get-started/quickstart) - walk through the full dev loop. * [Collaborators](/guides/collaborators) - let teammates deploy your app. Source: /cli-reference/overview.md --- # Command reference Per-command flags and examples for every `deepspace` command (large page - prefer search for one command). This page documents every CLI command. For an overview and the dev loop, see [CLI overview](/cli-reference/overview). ```bash npx deepspace <command> [options] ``` The surface is **noun-verb**: related commands are grouped under a noun (`auth`, `app`, `dev`, `test`, `secrets`, `workspace`, `integrations`). Running a group on its own prints its subcommands - `deepspace dev` does not start a server, `deepspace dev start` does. All commands support `--help` (or `-h`). The top-level `deepspace` command also accepts `--version`. Every command listed here supports `--json` unless noted. An unknown command path never falls back silently to root help. The CLI searches the **full** command tree - subcommands included - by lexical distance and answers with a concrete suggestion: `deepspace login` prints ``did you mean `deepspace auth login`?``, and a misplaced leaf like `deepspace whoami` resolves to its nested home `deepspace auth whoami`, not merely the nearest top-level group. Under `--json` the same situation is a coded refusal. Suggestions naming a destructive verb (`delete`, `rm`, `remove`, `drop`, `clear`, `undeploy`, `kill`) stay in the prose and are never handed back as an executable `action` - destructive commands have to be typed on purpose. ## Command map | Group | Commands | | -------------- | ----------------------------------------------------------------------------------------------------------------- | | *(top level)* | `status`, `activity`, `logs`, `push`, `pull`, `clone`, `releases`, `rollback`, `deploy`, `add`, `feedback` | | `auth` | `login`, `logout`, `whoami` | | `app` | `create`, `init`, `list`, `files`, `undeploy`, `transfer`, `collaborators`, `domain`, `usage`, `source`, `update` | | `dev` | `start`, `kill` | | `test` | `run`, `screenshot`, `accounts` | | `secrets` | `list`, `set`, `get`, `delete`, `upload`, `download`, `pull`, `configs` | | `workspace` | `new`, `attach`, `sync`, `list`, `status`, `land`, `drop` | | `integrations` | `list`, `info`, `invoke` | ## `auth` Session management. Login is shared across every app on the machine and stored at `~/.deepspace/session`. ### `auth login` Opens a browser to sign in with GitHub or Google. DeepSpace accounts are OAuth-only. ```bash npx deepspace auth login ``` | Flag | Description | | ------------------ | ------------------------------------------------------------- | | `--email <addr>` | Email address, password accounts only (or `$DEEPSPACE_EMAIL`) | | `--password <pw>` | Password. Discouraged - visible in `ps` and shell history | | `--password-stdin` | Read the password from stdin instead of `--password` | | `--json` | Single-line JSON result | The password flags sign in `@deepspace.test` [test accounts](#test-accounts) and internally provisioned maintainer accounts. **Regular DeepSpace accounts have no password.** To run an agent or a CI job as yourself, reuse the stored session instead - see [agents and CI](/cli-reference/overview#agents-and-ci), which also covers the `interactive_required` refusal. **Don't wrap browser login in `timeout N`.** The OAuth flow polls for up to ten minutes. Ctrl-C is safe - server-side state is preserved. Password login refuses through the same envelope as every other command: a `401` from the auth service is `invalid_credentials` (wrong email or password); any other refusal - or a session issued without a cookie or token - is `login_failed`. A `--json`/non-TTY call with no credentials is `interactive_required` (above). Credentials are stored per auth plane, so a login under `DEEPSPACE_ENV=staging` or a `DEEPSPACE_AUTH_URL` override never touches your production session - see [login state](/cli-reference/overview#login-state). ### `auth logout` Sign out and remove cached credentials. ```bash npx deepspace auth logout ``` ### `auth whoami` Print the current login state. This is the canonical login probe - it refreshes the JWT through the same path `dev`, `test`, and `deploy` use. ```bash npx deepspace auth whoami npx deepspace auth whoami --json ``` On not-signed-in it prints an error to stderr and exits non-zero. ## `app` Everything about the app as a platform object: its identity, its files, who can deploy it, and where its source lives. ### `app create` Scaffold a new app. Runs `create-deepspace`; all flags are forwarded. ```bash npx deepspace app create my-app npx deepspace app create my-app --template copilot ``` | Arg / Flag | Description | | ------------------- | ------------------------------------------------------------------ | | `[name]` | App name | | `--interactive` | Prompt for options instead of using defaults | | `--template <name>` | Starter template: `starter` (default) or `copilot` | | `--local <path>` | Use a local SDK monorepo checkout instead of the published package | `npm create deepspace@latest my-app` does the same thing without needing the CLI on PATH. ### `app init` Mint this app's immutable `DEEPSPACE_APP_ID` into `wrangler.toml`. ```bash npx deepspace app init npx deepspace app init --new-id # fork this repo as a SEPARATE app ``` | Flag | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `--new-id` | Replace the existing id - forks this repo as a separate app with new data, new secrets, new registration. The original keeps running. | | `--env <name>`, `-e` | `wrangler.toml` `[env.<name>]` block to stamp (each env is its own app) | The app id is the app's permanent identity. The `name` field in `wrangler.toml` is only the subdomain label; data, secrets, and collaborators key to the id, so renames are safe. "Already initialized" means **registered**, not merely id-shaped: `init` verifies the id with the platform before claiming done. The outcomes, and what `--json` carries: | Outcome | `status` / code | Notes | | ---------------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Fresh registration | `status: "registered"` | `appId`, `committedScaffold` (true when `init` made the initial commit), and a `git commit … wrangler.toml` action when the file is left uncommitted | | `--new-id` on a registered id | `status: "forked"` | `previousAppId` names the id it replaced; the original app is untouched | | Id already registered to you | `status: "already_initialized"` | exit 0, nothing written | | Id was minted locally by an older SDK and never registered | refuses `app_not_registered` | ships the executable `app init --new-id` action - there is no server-side state to migrate, so the fork is the one next command | | Id belongs to another account (a cloned repo) | refuses `not_app_owner` | no action: forking is a data decision | | `DEEPSPACE_APP_ID` is present but malformed | refuses `invalid_app_id` | ids are server-minted, so a malformed one was hand-edited - restore it, or pass `--new-id` to replace it; the result then carries `replacedMalformed` naming the value it overwrote | Every result also names the plane and the slot: `env` is the platform plane the registration lives on (`production` unless `DEEPSPACE_ENV` says otherwise - the same field `status --json` reports), and `wranglerEnv` is the `[env.<name>]` block (`null` for the top level). ### `app list` List every app you can access - both apps you own and apps shared with you as a collaborator, deployed and registered alike. The ROLE column shows your access on each. ```bash npx deepspace app list npx deepspace app list --json ``` ### `app files` Upload and manage the app's own files allocation - images, media, and other assets served from the app's origin. ```bash npx deepspace app files put logo.png npx deepspace app files put hero.jpg --key img/hero.jpg npx deepspace app files list --prefix img/ npx deepspace app files get img/hero.jpg --out ./hero.jpg npx deepspace app files rm img/hero.jpg ``` | Subcommand | Args / Flags | | ---------- | ------------------------------------------------------------------------------------- | | `put` | `<file>` (required), `--key <k>` (default: the file name), `--app <id\|name>` | | `list` | `--prefix <p>`, `--limit <n>` (1-1000, default 100), `--app <id\|name>` | | `get` | `<key>` (required), `--out <path>` (default: the key's file name), `--app <id\|name>` | | `rm` | `<key>` (required), `--app <id\|name>` | Keys are relative to the app (`logo.png`, `img/hero.jpg`). The platform owns the physical prefix and validates every key against it, so a key can never address another app. That prefix is part of the serving URL: files are reachable from your app's origin at `/api/files/apps/<resourceId>/<key>?scope=app`, which `put` prints for you (and returns as `path` under `--json`). The relative key alone does not resolve. See [large files and media](/guides/file-uploads#large-files-and-media) for when to reach for this instead of Git. **1 GiB per file; 25 MiB per request** — larger files upload in parts automatically. The server refuses declared active-content types (HTML, SVG, JS); disguised bytes can be stored but are always served with their stored type and `nosniff`, so they never execute in your app's origin. ### `app collaborators` Authorize other people to deploy your app. See the [collaborators guide](/guides/collaborators) for the full model. ```bash npx deepspace app collaborators list npx deepspace app collaborators add teammate@example.com npx deepspace app collaborators remove teammate@example.com npx deepspace app collaborators cancel invited@example.com npx deepspace app collaborators invites # invites waiting for YOUR email npx deepspace app collaborators accept app_01H… # accept one, no email link needed ``` | Subcommand | Args / Flags | | ---------- | -------------------------------------------------------------------------------- | | `list` | `--app <id\|name>`, `-a` | | `add` | `<email>` (required), `--app <id\|name>` | | `remove` | `<email>` (required), `--app <id\|name>` | | `cancel` | `<email>` (required), `--app <id\|name>` - cancels a pending, un-accepted invite | | `invites` | none - lists pending invites addressed to the signed-in email | | `accept` | `<app-id>` (required) - accepts an invite as the signed-in invitee | Collaborators have **owner-equivalent deploy access**. Only add people you trust. ### `app undeploy` Remove a deployed app. ```bash npx deepspace app undeploy # reads DEEPSPACE_APP_ID from wrangler.toml npx deepspace app undeploy my-app # explicit app id or subdomain name ``` | Arg / Flag | Description | | -------------- | ----------------------------------------------------------------------------------------- | | `[name]` | App id or subdomain name (positional). Reads `wrangler.toml` if omitted. | | `--env <name>` | `[env.<name>]` block whose deployed app to remove. Ignored if a positional name is given. | | `--yes` | Skip the interactive confirmation | **At an interactive terminal `undeploy` confirms before the URL goes dark**, and the default answer is **No**. The prompt names the app (its wrangler `name` and id) and states exactly what happens: the URL stops serving immediately and the app's data - records, messages, canvas state, cron history - is destroyed with the worker's Durable Objects, while secrets and the registration stay and the name is reserved for you for 30 days. Declining refuses with the code `undeploy_declined` and changes nothing. Pass `--yes` to skip the prompt. Scripts and agents are never prompted: if stdin is not a TTY, or `--json` was passed, the command runs straight through - the invocation is its consent - so a piped or `--json` caller cannot hang on it. Undeploying an app that is not serving is a no-op, and says so: the result carries `alreadyUndeployed: true` with an empty `releasedHosts`, and the human line reads "was already offline". A real takedown lists the released hosts and `alreadyUndeployed: false`. Either way the app keeps its id; a later `deploy` revives it - see [undeploy and revival](/guides/app-identity#undeploy-and-revival). ### `app transfer` Transfer app ownership to another DeepSpace user. Two-sided: the owner offers, the recipient accepts. ```bash npx deepspace app transfer offer newowner@example.com npx deepspace app transfer status npx deepspace app transfer accept npx deepspace app transfer cancel # either party ``` The recipient must have signed in to DeepSpace at least once. Against an email with no DeepSpace user, `offer` fails with `user_not_found`. (This is unlike `collaborators add`, which emails an invite to strangers.) ### `app domain` Buy, attach, and manage custom domains. See the [custom domains guide](/guides/custom-domains). ```bash npx deepspace app domain search <query> npx deepspace app domain buy <domain> npx deepspace app domain list npx deepspace app domain status <domain> npx deepspace app domain attach <domain> npx deepspace app domain detach <domain> npx deepspace app domain renew <domain> ``` | Subcommand | Purpose | | ---------- | ---------------------------------------------------------------------- | | `search` | Search for available domains | | `buy` | Buy a domain and attach it to an app | | `list` | List your domains | | `status` | Show details for one domain | | `attach` | Re-point a domain at a different app | | `detach` | Stop routing the domain (keeps the registration; auto-renew unchanged) | | `renew` | Toggle auto-renewal at the registrar | ### `app usage` Show account-wide credit balance, quota headroom, and per-integration spend. The result is not scoped to the selected app. ```bash npx deepspace app usage npx deepspace app usage --json ``` JSON output includes `"scope": "account"` so automation does not mistake it for per-app usage. ### `app source` Report the app's one authoritative Git repository. **Read-only** — source is inferred from use, never declared (see [source control](/guides/source-control)). ```bash npx deepspace app source # report: claimed source, or unclaimed npx deepspace app source --json # { source: {...} | null, revision, ... } ``` | Arg / Flag | Description | | ------------------------ | --------------------------------- | | `--app <id\|name>`, `-a` | Target app (default: current app) | The old setter form (`app source github|deepspace`) refuses with `source_inferred`. An app has exactly one source of record: under DeepSpace source, `push`/`pull`/`clone`/`workspace` operate on the cloud repo and `deploy` synchronizes before shipping; under GitHub (claimed under v0.25 and earlier, or inferred from the checkout's remote on an unclaimed app), you manage commits with normal Git and `deploy` ships the local working tree without touching Git. The first `deepspace push` — or an unclaimed app's deploy sync — claims DeepSpace source permanently; `status --json` reports an unclaimed app's next-deploy inference as `sourceInference`. `deploy` removes a claimed-GitHub app's stale `space` remote. ### `app update` Inspect an app and print the exact work needed to move it to the running CLI's SDK version. Run the **target** CLI before installing the target SDK - it owns that release's checklist: ```bash npx deepspace@latest app update --json ``` `app update` is deliberately read-only: it does not edit `package.json`, rewrite source, stamp `deepspace.migrations.json`, run an installer, or require DeepSpace source control. Every successful result carries `writes: []` and no executable action. The JSON result contains: | Field | Meaning | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `status` / `ready` | `aligned` is the only ready state. Other successful states are `guidance_available`, `dependency_unverified`, `cli_version_behind`, and `version_gap_too_wide`. | | `currentSpec` / `targetVersion` | The app's declared `deepspace` spec and the running CLI's package version. The CLI you invoked is the sole target authority. | | `dependencies` | Manual `package.json` edits, including the compatible direct `ai` dependency when the app declares one. Local, workspace, VCS, and URL SDK specs are left under the developer's control and reported as unverified. | | `migrations` | Outstanding app-owned changes, each with an id, description, candidate files, and guidance. After applying and validating one, add its id to `deepspace.migrations.json`; mark it not applicable there when the named seam does not exist. | | `manualInstructions` | Policy or dependency checks that cannot be inferred safely, such as an app-owned users-schema visibility choice. | | `steps` | The ordered checklist assembled from those fields: edit dependencies, install when needed, apply migration guidance, type-check, test, review, and commit. | | `guidanceUrl` | The release-specific migration reference. | A malformed `package.json` or `deepspace.migrations.json` refuses with `invalid_package_manifest` or `invalid_migration_manifest`; neither file is repaired automatically. A wide version gap is a successful `version_gap_too_wide` guide rather than a failed partial migration. The full sequence, and what to do with a scaffold that predates server-minted ids, is in [Updating an app](/guides/updating). ## `dev` ### `dev start` Run the app locally with Vite and the worker in-process, with HMR. ```bash npx deepspace dev start npx deepspace dev start ./my-app npx deepspace dev start --port 5180 # parallel apps npx deepspace dev start --env staging ``` | Arg / Flag | Default | Description | | -------------------- | ----------------------------- | ----------------------------------------------------------------------- | | `[dir]` | `.` | App directory (positional) | | `--port <n>` | `5173` (or `$DEEPSPACE_PORT`) | Port to bind | | `--env <name>`, `-e` | - | `[env.<name>]` block to run. Applies the env's overrides at build time. | `dev start` regenerates `.dev.vars` **whole** on every run - SDK-managed keys plus the selected config from the [secrets store](/guides/secrets). Hand edits are overwritten; set values with `secrets set`, and restart dev to pick up store changes. If the store refresh fails, `dev start` aborts rather than run against stale values. Under `--json`, the server's own output still streams through. `dev start` emits **two** envelopes: a readiness line (`{"ok":true,"ready":true,"url":...}`) the moment the server is up, and a final exit envelope when it stops - the last line of output, not the only one. ### `dev kill` Stop the local dev server and any orphaned `workerd` processes. ```bash npx deepspace dev kill npx deepspace dev kill --port 5180 npx deepspace dev kill --all ``` | Flag | Default | Description | | ------------ | ----------------------------- | --------------------------------------------------------------- | | `--port <n>` | `5173` (or `$DEEPSPACE_PORT`) | Port the dev server is bound to | | `--all` | off | Also kill stray `workerd`/`wrangler` processes across all ports | **Don't kill a parallel session's processes.** If a sibling session is on 5173, run yours on `--port 5174` instead of using `--all`. ## `test` ### `test run` Run tests against the dev workers. Auto-installs Playwright and Chromium on first run. ```bash npx deepspace test run # default = smoke + api npx deepspace test run smoke npx deepspace test run e2e npx deepspace test run unit # vitest npx deepspace test run all npx deepspace test run tests/checkout.spec.ts # one file npx deepspace test run --port 5180 npx deepspace test run e2e --grep "sign in" # only matching Playwright tests ``` | Arg / Flag | Default | Description | | -------------------- | ----------------------------- | -------------------------------------------------------------------------------- | | `[suite]` | smoke + api | `smoke`, `api`, `e2e`, `unit`, `all`, or a path ending in `.spec.ts` | | `--port <n>` | `5173` (or `$DEEPSPACE_PORT`) | Port for Vite / Playwright's `webServer` | | `--env <name>`, `-e` | - | `[env.<name>]` block to test (uses secrets config `<name>` by default) | | `--grep <pattern>` | - | Run only Playwright tests whose title matches (forwarded to Playwright `--grep`) | | `--project <name>` | - | Run only the named Playwright project (forwarded to `--project`) | | `--headed` | off | Run Playwright headed instead of headless | The default suite (`smoke + api`) does not include `e2e`; run `test run e2e` or `test run all` to exercise end-to-end specs, including any you add. An unrecognized `[suite]` refuses with `unknown_suite` and names every way to narrow a run - the five suite names, a path to a single spec file (`tests/<name>.spec.ts`), and `--grep <pattern>` for part of a suite. Without those, the reader's next move was moving spec files out of `tests/` to isolate one. `test run` requires a logged-in user - it mints an app-owner JWT into the regenerated `.dev.vars`. The runner owns its web server: a live server already on the port refuses `port_in_use` before any install or Playwright work (a previous run's server still shutting down gets a bounded grace first) - the same guard and remedy `dev start` uses. Stop it with `deepspace dev kill` or pass another `--port`. **The Playwright dependency preflight writes to stderr under `--json`**, like the suite itself, so it cannot corrupt the stdout envelope. On Linux as root it also announces itself before starting: installing Chromium's system libraries shells out to `apt-get`, which is minutes of transcript on a cold container - the line up front is what distinguishes that from a hang. Under `--json` the suite streams on **stderr** and stdout carries exactly one JSON envelope, so `npx deepspace test run --json | jq` works with no last-line handling and the live Playwright/vitest transcript stays visible. (`test screenshot --json` routes its child's output to stderr the same way, so its stdout also carries exactly one envelope.) The envelope names what the run left out: `skippedSpecs` lists the spec files the chosen suite did not run - the default `smoke + api` suite reports every spec outside it - so a green result cannot be mistaken for full coverage. Human output prints the same fact as a `skipped N spec file(s) ...` line. ### `test screenshot` Capture a Playwright screenshot of any URL. Shares the same Chromium install as `test run`. ```bash npx deepspace test screenshot http://localhost:5173/ out.png npx deepspace test screenshot http://localhost:5173/ out.png --full-page npx deepspace test screenshot http://localhost:5173/ mobile.png --viewport 390x844 npx deepspace test screenshot http://localhost:5173/ out.png --wait-for-timeout 500 ``` | Arg / Flag | Description | | -------------------------------- | -------------------------------------------------------------------- | | `<url>` | URL to capture (required) | | `<output>` | Output image path (required) | | `--full-page` | Capture the full scrollable page | | `--viewport <WIDTHxHEIGHT>` | Set positive browser viewport dimensions, for example `390x844` | | `--wait-for-timeout <ms>` | Milliseconds to settle before capture (default `1000`; `0` disables) | | `--wait-for-selector <selector>` | Also wait for a visible selector before capture (off by default) | ### `test accounts` Manage `@deepspace.test` accounts for multi-user Playwright tests. Unlike your own account, these sign in with **email and password** - that is what makes them usable as fixtures. They are heavily restricted in exchange; see [restrictions](/guides/testing#what-test-accounts-cannot-do). Only an OAuth-authenticated developer can create them. ```bash npx deepspace test accounts list npx deepspace test accounts create npx deepspace test accounts delete --email alice-1@deepspace.test npx deepspace test accounts clear ``` | Subcommand | Purpose | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `create` | Create a test account. `--name` sets the `users()` selector; omit it and the selector defaults to the email's local part | | `list` | List your test accounts. `--usable` shows only those with locally saved credentials (the ones `users()` can drive); passwords are masked unless you pass `--reveal`. `--json` adds a `usableByFixture` flag, and honours `--reveal` the same way: without it the `password` field is **omitted**, with it the locally saved password (or `null`) is included | | `delete` | Delete a test account by `--email` or `--id`. Immediate - it **never** prompts, and has no `--yes` | | `clear` | Delete all your test accounts (or those matching `--label`). This one confirms, and `--yes` skips it | Every account prints a `Selector:` - the string to pass `users(['Selector'])`. Test accounts cannot deploy apps or be added as [collaborators](/guides/collaborators). **`create` never echoes the password**, on either surface. You supplied it with `--password`, so repeating it only puts a live credential into terminal scrollback, CI logs, and agent transcripts. It is written 0600 to the local test-accounts file, and both surfaces name that file instead - `savedTo` in `--json`. Read it back deliberately with `test accounts list --reveal`. `delete` is unambiguous - one named account - which is why it runs immediately; `clear`, which empties the whole pool, is the verb that confirms. `delete --help` says so, so a teardown script does not have to discover it by hanging. ## `secrets` Manage the app's secrets store. Secrets live in named **configs** (default `prd`); `--env <name>` selects that environment's app id *and* its config. The store is the only deploy input - `.dev.vars` is a generated cache. The [secrets guide](/guides/secrets) carries the model, caps, and troubleshooting. ```bash npx deepspace secrets list npx deepspace secrets set STRIPE_KEY=sk_live_... OPENAI_KEY=sk-... npx deepspace secrets get STRIPE_KEY --plain npx deepspace secrets delete STRIPE_KEY npx deepspace secrets upload .env.production npx deepspace secrets download --format json npx deepspace secrets pull # refresh the .dev.vars cache npx deepspace secrets configs list ``` Every subcommand accepts these: | Flag | Description | | ----------------------- | --------------------------------------------------------------------- | | `--app <id>`, `-a` | App id (default: `DEEPSPACE_APP_ID` from the nearest `wrangler.toml`) | | `--config <name>`, `-c` | Config name (default: the `--env` name, or `prd`) | | `--env <name>`, `-e` | `[env.<name>]` slot - selects that env's app id and config | | Subcommand | Extra flags | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `list` | `--only-names` - print names only, omitting values and metadata | | `set` | `<KEY=value>` (repeatable, required) | | `get` | `<key>` (required), `--plain` - print the plaintext value | | `delete` | `<key>` (repeatable, required) | | `upload` | `<file>` (required; `-` for stdin), `--replace` - delete keys missing from the file | | `download` | `--format dotenv\|json\|shell` (default `dotenv`) | | `pull` | - | | `configs` | `list`; `create <name>` with `--copy-from <existing>` for a server-side copy (refuses to copy over an existing config); `delete <name>` | The app is resolved **before** the token is read, through the same resolver `deploy` uses, so a missing target never surfaces as `not_authenticated`: outside an app directory it is `not_in_app_repo`, inside one whose `wrangler.toml` has no id it is `app_not_initialized` (with the `app init` action), and a malformed id is `invalid_app_id`. `upload` with a path that does not exist refuses `file_not_found` (pass a dotenv/JSON path, or `-` for stdin). A transport failure reaching the deploy service - offline, DNS, refused connection - is `network_error`, naming the URL and `DEEPSPACE_DEPLOY_URL`, rather than a bare `fetch failed`. ## `workspace` Durable parallel-agent workspaces: a server-side ref plus a local worktree, so several agents or people can work on one app without colliding. ```bash npx deepspace workspace new -t "add billing page" npx deepspace workspace status npx deepspace workspace sync npx deepspace workspace land npx deepspace workspace list npx deepspace workspace attach ws_... npx deepspace workspace drop ``` | Subcommand | Args / Flags | | ---------- | -------------------------------------------------------------------------------------------------------------------- | | `new` | `--task <text>`, `-t` (required), `--base <rev>`, `--dir <path>` (default `.deepspace/ws/<id>`), `--app` | | `attach` | `<id>` (required), `[dir]`, `--app` | | `sync` | `--workspace <id>`, `-w` (default: inferred from the `ws/<id>` branch), `--app` | | `list` | `--all` (include landed/dropped), `--limit <n>` (default 50), `--app` | | `status` | `--workspace <id>`, `-w`, `--app` | | `land` | `--into <branch>` (default: cloud repo default branch), `--workspace <id>`, `--keep-worktree`, `--validate`, `--app` | | `drop` | `[id]` (default: inferred from the branch), `--keep-worktree`, `--app` | `land --validate` runs the project validation on the merged tree before publishing trunk, and exits 2 on failure. ## Version control These operate on the app's cloud repo when the app uses `deepspace` source. Under `github` source all three refuse identically with `source_managed_by_github`, whose `--json` carries `repository` and `appId` - use normal Git instead. See [the refusal the source verbs share](/guides/source-control#the-refusal-the-source-verbs-share). ### `push` Push local git commits to the app's cloud repo. ```bash npx deepspace push npx deepspace push --branch feature-x ``` | Flag | Description | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `--branch <name>`, `-b` | Branch to push (default: the current branch) | | `--app <id\|name>`, `-a` | Target app | | `--force` | Allow a non-fast-forward ref move. Guarded: refuses whenever the remote tip is a commit your branch does not contain, so no work is silently dropped. | **Push size ceilings: 20 MiB per object, 32 MiB of compressed history per push.** An oversized push is refused with `push_too_large`. Untracking the file (`git rm --cached` + `.gitignore`) does **not** fix it - the blob stays reachable from the commit that introduced it and the next push is refused identically. You must drop the file from the commits that carry it, or rewrite history if it was already pushed. Large media belongs in [`app files`](#app-files), not Git. Two different empty-repo refusals, deliberately kept apart: * `no_commits` - the branch genuinely has no commits. On an **uninitialized scaffold** (`wrangler.toml` still holds the `__APP_ID__` placeholder) this is `actionRequired: true` and **exit 2**, carrying an executable `app init` action - the same tier as `deploy`'s `app_not_initialized`, since it is the same remedy. `app init` registers the app *and* makes the initial commit, so committing first would put an unregistered placeholder into history. On an already-registered app the same code stays an ordinary exit 1 with no action: "commit first" is not one command. * `unknown_branch` - the repo has commits but the named branch does not exist (a typo'd `-b`). Committing on the current branch would never make the missing ref appear, so this gets its own code and remedy: create or switch to the branch first. `push`, `pull`, and `deploy` share one more guard: a worktree in the middle of a merge, rebase, cherry-pick, or revert refuses `merge_in_progress` before touching the network. `HEAD` is the pre-operation commit then, so a push would publish a commit carrying none of the in-flight work and report success, and a pull would report `up_to_date` having touched nothing. The refusal names both remedies (`--continue` or `--abort`) and ships no single action, because finishing and abandoning are different decisions. ### `pull` Fetch the app's cloud repo and fast-forward the local branch. ```bash npx deepspace pull npx deepspace pull --branch main ``` Exits 2 only when one executable local continuation remains. ### `clone` Clone an app's cloud repo into a new directory. ```bash npx deepspace clone my-app npx deepspace clone app_01ABC... ./local-dir ``` | Arg | Description | | ------- | --------------------------------------------------- | | `<app>` | App id (`app_...`) or subdomain name (required) | | `[dir]` | Target directory (default: the app name you passed) | ### `releases` List the app's deploy history. Releases are immutable. ```bash npx deepspace releases npx deepspace releases --limit 50 ``` | Flag | Description | | ------------------------ | ------------------------------------------------------- | | `--app <id\|name>`, `-a` | Target app (default: the surrounding app directory) | | `--env <name>`, `-e` | `[env.<name>]` slot - selects that environment's app id | | `--limit <n>` | Max entries (default 20) | Under `--json`, each entry carries `rollbackAvailable` - storage pressure can evict an old bundle while its ledger row remains. What a release records depends on the app's source mode; see [Releases and rollback](/guides/releases-and-rollback). `releases`, `status`, and `activity` describe a release's source through one shared formatter, so a GitHub-source release reads `GitHub · owner/repo` on all three human surfaces instead of the "no source recorded" they used to print while `--json` knew better. The full mapping is in [how a release names its source](/guides/releases-and-rollback#how-a-release-names-its-source). ### `rollback` Re-deploy a prior release's exact bundle. No rebuild happens. ```bash npx deepspace rollback # the previous release npx deepspace rollback rel_01ABC... ``` | Arg / Flag | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------- | | `[release]` | Release id from `deepspace releases` (default: the previous release) | | `--app`, `-a` / `--env`, `-e` | Target app / environment | | `--allow-do-deletion` | Proceed even though the target release declares fewer Durable Object classes than the current one | `--allow-do-deletion` **deletes the stored data** of every Durable Object class the target release drops. Without the flag, `rollback` refuses that case. Spell out the exact loss and get the owner's explicit approval before passing it. A release whose bundle was evicted refuses with `no_bundle` - pick another release marked `rollbackAvailable` in `releases --json`. Every rollback appends a new release fact; see [Releases and rollback](/guides/releases-and-rollback). ## `deploy` Build and deploy the app. ```bash npx deepspace deploy npx deepspace deploy ./my-app npx deepspace deploy --env staging ``` | Arg / Flag | Description | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `[dir]` | App directory (default: current directory) | | `--env <name>`, `-e` | `[env.<name>]` block to deploy. Omit for the top-level config. | | `--rename` | Confirm that a changed wrangler `name` renames this app. Its URL moves; data, secrets, and collaborators travel - the [display name does not](/guides/app-identity#renaming-an-app). Without the flag an interactive prompt asks. | | `--claim-released` | Platform admins only: claim a name still inside its 30-day release cooldown. The previous owner permanently loses their reserved reclaim. Non-admin accounts are refused. | | `--push` / `--no-push` | Sync DeepSpace source before deploying (default on). GitHub source always deploys the local working tree without Git operations. | | `--ignore-stale` | Deploy even if someone released a newer version since you last synced (skips the stale-base guard) | | `--json` | Single-line JSON result; human output goes to stderr | The subdomain comes from the `name` field in `wrangler.toml`. Names are **globally unique** across all of DeepSpace - a name already claimed by another app is refused with `The name <host> is taken by another app.` A repo without a `DEEPSPACE_APP_ID` gets one minted and written into `wrangler.toml` by its first deploy - commit it. Secrets ship from the app's [remote store](/guides/secrets); deploy never reads `.dev.vars` (it rewrites it). If the selected secrets config does not exist, deploy refuses with `secrets_config_missing` and returns the executable `secrets configs create` action - see [missing is not empty](/guides/secrets#missing-is-not-empty). The success envelope reports **what tree shipped** as `branch` and `dirty` (both `null` outside a usable Git repo), and, when the deploy renamed the app, `renamedFrom` plus `staleDisplayName`. See [what a release records](/guides/releases-and-rollback#what-a-release-records-depends-on-the-source) and [renaming an app](/guides/app-identity#renaming-an-app). Once the edge confirms the release (`serving: confirmed`), `deploy` sends the new worker **one request** - the template's own `GET /api/auth/ok` health route - so the app's Durable Objects exist before any visitor arrives; that is what arms a deployed [cron schedule](/guides/scheduled-jobs#when-a-schedule-starts-running). It is best effort: if the wake fails, the first real request arms it instead. **Who may deploy, and where from.** `forbidden` names both sides: the app id and the account you are signed in as (`app_… belongs to another account - you are signed in as you@example.com, who is neither its owner nor a collaborator`), and the two remedies - have the owner run `app collaborators add <your email>`, or `app init --new-id` to publish the code as your own app. An id no server has registered (an older SDK's locally minted scaffold) is `app_not_registered`, with `app init --new-id` as the remedy. **One deploy at a time, per checkout and per app.** Two deploys of one directory race on the shared `dist/`, so `deploy` takes a local lock at `.deepspace/deploy.lock` before any work and refuses a second run with `deploy_in_progress`, naming the other run's pid and start time (`lockPath` and `holder` in `--json`). A lock left by a dead process is reclaimed automatically; only remove it by hand when no deploy is running. Separately, when another deploy of the same app **from another checkout** is between prepared and live, the platform answers `release_in_progress`: this run built and uploaded fine but did not release, so it exits **2** with an executable retry action - wait a moment and run the same deploy again, and check `deepspace releases` if it keeps refusing for more than a couple of minutes. **`deploy` needs a committed worktree.** Under DeepSpace source with sync on, uncommitted changes are refused with `dirty_worktree`, because a deploy records the commit it ships. Commit them (WIP commits are fine), or pass `--no-push` to deploy without source lineage. A worktree mid-merge or mid-rebase is `merge_in_progress` instead (see [push](#push)). The full refusal taxonomy (`dirty_worktree`, `behind_trunk`, `stale_base`, and the concurrency codes above) is in [Releases and rollback](/guides/releases-and-rollback#the-refusal-taxonomy). ## `status` One screenful of current state: environment, session, app, workspace, and live release. The fastest orientation command in a fresh shell or sandbox. ```bash npx deepspace status npx deepspace status --json ``` | Flag | Description | | -------------------- | ----------------------------------------------------------- | | `--env <name>`, `-e` | `[env.<name>]` slot - reads that environment's app identity | **The environment line is always first, and never elided.** `status` opens with `Env` naming the plane the next command will mutate (`production` unless `DEEPSPACE_ENV` says otherwise), plus any of `DEEPSPACE_DEPLOY_URL` / `DEEPSPACE_AUTH_URL` / `DEEPSPACE_API_URL` / `DEEPSPACE_PLATFORM_URL` that are set: ``` Env production ``` A second `Services` line spelling out the four resolved service URLs is printed only when there is something to notice - a non-production plane, or at least one URL override: ``` Env staging · DEEPSPACE_DEPLOY_URL set Services auth https://... · api https://... · platform https://... · deploy https://... ``` `--json` carries both unconditionally: `env` is the plane string and `services` is the resolved `{ auth, api, platform, deploy }` object, whether or not the human `Services` line was printed. `urlOverrides` lists the override variable names when any are set. Check `env` before any mutating command rather than inferring the plane from the shell. ## `activity` The app's coordination feed: pushes, workspaces, releases. ```bash npx deepspace activity npx deepspace activity --follow npx deepspace activity --since now --limit 100 ``` | Flag | Description | | ------------------------ | ------------------------------------------------------------------------------------------- | | `--since <cursor>` | Events after this cursor, or `now`. One-shot defaults to `0`; `--follow` defaults to `now`. | | `--limit <n>` | Max events per page (default 50) | | `--follow` | Keep polling for new events | | `--app <id\|name>`, `-a` | Target app | One-shot `--json` returns a single document - `{ events, cursor, hasMore }`; page with `--since` while `hasMore` is true, and persist the last cursor for continuity. `--follow --json` is an **NDJSON stream**: one `ready` frame (`{"type":"ready","appId":...,"cursor":...}`), then one `activity` frame per event carrying the event and its cursor. Recoverable polling failures emit `transport` frames with `"state":"retrying"` and never advance the cursor. Events are facts, not instructions. ## `logs` Show production logs for a deployed app. ```bash npx deepspace logs npx deepspace logs --follow npx deepspace logs --since 2h --level error npx deepspace logs --search "checkout" --json ``` | Flag | Description | | ------------------------ | ------------------------------------------------------------------------- | | `--app <id\|name>`, `-a` | Target app (default: `DEEPSPACE_APP_ID` from the nearest `wrangler.toml`) | | `--env <name>`, `-e` | `[env.<name>]` slot - reads that env's own app id | | `--follow`, `-f` | Keep polling for new logs until Ctrl+C (\~3s cadence) | | `--since <window>` | `30s`, `15m`, `2h`, `24h`, `7d`, or an ISO timestamp (default `15m`) | | `--level <lvl>` | `debug`, `log`, `info`, `warn`, `error` | | `--search <text>` | Only events whose message contains this text | | `--limit <n>` | Max events per fetch (default 100, max 500) | | `--json` | Emit **NDJSON** - one event object per line | `--json` is NDJSON in both snapshot and follow modes. Log events never carry a `type` field; frames that do are metadata: an empty snapshot window emits `{"type":"meta","count":0,...}` so "ran, no events" is distinguishable from a crash, and a truncated snapshot emits `{"type":"meta","truncated":true}` - narrow with `--since`/`--level`, or raise `--limit`. An app that has never been deployed has no logs and never will until it is, so `logs` refuses `app_not_deployed` instead of printing an empty window - with the `deploy` action when you are in the app's own checkout. ### Reading an event Each event is `{ id, timestamp, level, eventType, message, source?, outcome?, request?, exception? }`. Three fields are easy to misread: * **`eventType`** is `log` for a `console.*` line, `request` for the invocation summary of one request, and `exception` **only for an exception that escaped the Worker** (uncaught). An error your code catches and logs - including what the template's `app.onError` does before answering 500 - is a `log` line at level `error`. * **`outcome`** rides `request` (invocation) events only and is the runtime's verdict on whether the Worker **returned** (`ok`, `exception`, `exceededCpu`, `canceled`, …) - not whether the app succeeded. An action that throws inside the app's own error handling and answers 500 is `outcome: "ok"`. Triage on `request.status`, `level`, and `eventType === "exception"`, never on `outcome !== "ok"` alone. * **`exception`** (`{ name, message, stack? }`) is present on `eventType: "exception"` and on browser-reported errors (`source: "client"`). The Workers runtime renders a **logged** Error object as its stack frames only - the message never reaches the store - so when you catch and log, put the message in the line itself: ``console.error(`[error] … ${err.message}`, err.stack)``. The scaffold's `app.onError` does exactly that, and so do the SDK's own rooms. Scheduled work logs one line per run - `[cron] <task> ok <ms>ms` or `[cron] <task> failed <ms>ms: <message>` (the stack rides along as plain text) - so `logs --search cron` finds every run by task name; a failed background job logs `[jobs] <type> (<id>) failed: <message>` the same way. See [scheduled jobs](/guides/scheduled-jobs#when-a-schedule-starts-running). ## `add` Install a scaffold feature into the current app. ```bash npx deepspace add --list npx deepspace add --info messaging npx deepspace add messaging npx deepspace add messaging ./my-app --install ``` | Arg / Flag | Description | | --------------- | -------------------------------------------------- | | `[feature]` | Feature to install (positional) | | `[dir]` | App directory (positional, default `.`) | | `--list`, `-l` | List available features and exit | | `--info <name>` | Show details about a feature and exit | | `--install` | Run your package manager after adding dependencies | Run `--list` for the current feature set - it changes between releases. ## `integrations` Discover and call third-party integration endpoints, billed to the logged-in user. ```bash npx deepspace integrations list npx deepspace integrations info openai/chat-completion npx deepspace integrations invoke openai/chat-completion --body '{"messages":[]}' npx deepspace integrations invoke openai/chat-completion --body-file req.json cat req.json | npx deepspace integrations invoke openai/chat-completion --body-file - ``` | Subcommand | Args / Flags | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list` | `--json` | | `info` | `<target>` (required) - `<integration>/<endpoint>` | | `invoke` | `<target>` (required), `--body <json>` / `-d`, `--body-file <path>` / `-f` (`-` for stdin), `--timeout <ms>` (default 120000), `--yes` / `-y` to skip the paid-call cost confirmation | **A paid call needs consent.** Without `--yes`, `invoke` looks the endpoint's price up first. At an interactive terminal (stdin **and** stdout are TTYs, no `--json`) it asks `<endpoint> costs <price>, billed to your account. Continue?` with the default **No** - declining is a success (`cancelled: true`), nothing was billed. Anywhere else - `--json`, a piped stdin such as `--body-file -`, CI - it refuses `cost_confirmation_required` **before** any call is made; pass `--yes` to confirm the spend. Free endpoints proceed without a prompt. `info` prints an example body you can copy: the catalog's own example when it has one, otherwise one **synthesized from the input schema's `required` keys** with placeholders from the schema (`example`, `default`, first `enum`) or the type (`"<string>"`, `0`, `false`), so the body an agent copies is never `{}` for an endpoint that rejects `{}`. `--json` carries the same body as `example`; it is `null` only when neither the catalog nor the schema says anything. ## `feedback` Submit a bug report or feature request to DeepSpace. ```bash npx deepspace feedback "Deploy hangs on large assets" npx deepspace feedback "Add dark mode" --type feature -m "Details here" --yes ``` | Arg / Flag | Description | | ------------------------ | ------------------------------------------------- | | `[title]` | Short summary (prompted if omitted in a terminal) | | `--type <t>`, `-t` | `bug` (default), `feature`, or `other` | | `--message <text>`, `-m` | Details / description | | `--yes` | Skip the confirmation prompt | ## `git-credential` Git credential helper. Installed automatically by the CLI so `git` can authenticate against the cloud repo. **Not for direct use.** ## Global flags | Flag | Available on | Purpose | | -------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--help`, `-h` | every command | Print help | | `--version` | top-level `deepspace` | Print CLI version | | `--json` | nearly every command | Single-line JSON envelope for scripts and agents (`logs` and `activity --follow` emit NDJSON; `dev start` / `test screenshot` envelopes follow the child stream on stdout; `test run` streams the suite on stderr and keeps stdout to the one envelope) | | `--yes` | `app undeploy`, `feedback`, `integrations invoke`, `test accounts clear` | Skip the confirmation prompt | ## Environment | Variable | Used by | Purpose | | ------------------------ | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `DEEPSPACE_EMAIL` | `auth login` | Email for a password account (test or internally provisioned) | | `DEEPSPACE_PASSWORD` | `auth login` | Password for that account. Regular DeepSpace accounts have none | | `DEEPSPACE_PORT` | `dev`, `test` | Default port when `--port` is not passed | | `DEEPSPACE_ENV` | every command | Selects the platform plane (`production` when unset). Also selects which **stored credential** the CLI reads - each plane keeps its own `~/.deepspace/session` pair, see [login state](/cli-reference/overview#login-state) | | `DEEPSPACE_AUTH_URL` | `auth`, `test accounts` | Override auth-worker URL (advanced/testing). Like `DEEPSPACE_ENV`, selects its own stored credential | | `DEEPSPACE_API_URL` | `app`, `integrations`, `secrets` | Override api-worker URL (advanced/testing) | | `DEEPSPACE_DEPLOY_URL` | `deploy`, `push`, `pull`, `releases` | Override deploy-worker URL (advanced/testing) | | `DEEPSPACE_PLATFORM_URL` | `app files` | Override platform-worker URL (advanced/testing) | `DEEPSPACE_EMAIL` and `DEEPSPACE_PASSWORD` are consulted **only** when stdin is non-interactive or `--json` was passed, so ambient values in a dev shell cannot hijack an interactive `deepspace auth login`. ## See also * [CLI overview](/cli-reference/overview) - dev loop, and running agents and CI as yourself * [Collaborators](/guides/collaborators) - sharing deploy access * [Quickstart](/get-started/quickstart) - first-time walkthrough * [Custom domains guide](/guides/custom-domains) - purchase and attach flow * [Testing guide](/guides/testing) - the test runner in context Source: /cli-reference/commands.md