<!--
  Generated from docs/index.html by scripts/build_docs_md.py.
  Do not hand-edit - edit the .html and re-run the script.
  Canonical page: https://caspian.md/docs/
-->

# Build a Caspian canvas

A canvas is a small, sandboxed web app that Caspian loads and lets people pin notes onto. Every canvas that ships with Caspian - Image, 3D Model, Map, Webpage - is written against the exact same contract described on this page, so anything you can build as a static HTML/JS bundle can be wired up to notes the same way.

## Prompt for LLMs

Describe what you want your canvas to do, then copy the generated prompt into Claude, ChatGPT, or any other LLM. It fills in Caspian's exact folder layout, manifest fields, and bridge protocol, so the model has everything it needs to hand back a working canvas without you explaining the contract yourself.

*(The interactive prompt generator itself only works on the live page.)*

## Overview

Caspian never runs your code with any special privilege. Your canvas renders inside a sandboxed iframe with no Node, no filesystem, and no direct access to the Workbench - it can only reach notes through a small message-based **bridge**, and only for the methods its manifest has been **approved** to call. The host handles everything else: loading your files, tracking which notes belong to your canvas, and persisting whatever per-instance state you ask it to keep.

> **The short version:** write a static site that calls `callBridge()` to read and create notes, declare what it needs in `canvas.json`, build it to a `dist/` folder, then install that folder from Caspian's *Create a new project → I'll write my own project* option.

## Folder layout

A canvas is a folder with two required members, plus a few optional ones:

```text
my-canvas/
  canvas.json          <- required: the manifest - id, version, entry point, capabilities
  dist/                <- required: your built, static renderer - the only thing Caspian loads
    renderer.html
    renderer.js
    members.html       <- optional: more pages, if canvas.json lists them (see Pages)
  versions/            <- optional: the project's context file, read by Copilot
    context.md         <- Context: brief, decisions, current state, change log, notes
  database.json        <- optional: the project's user accounts (User registration only)
  src/                 <- optional: your own source, never read by Caspian
```

Caspian only ever serves files out of `dist/`, sandboxed to that directory - it can't reach anything outside it, including your own `src/`. Name the entry file whatever you like; it's whatever `canvas.json`'s `renderer` field points at.

`versions/context.md` is the project's [Context](./getting-started/#context). Copilot reads it on every request, and the app never serves it to your renderer. You don't have to write it: if it's missing, Caspian creates it the first time it's needed (opening Context, or Copilot working on the project), using your manifest's `name` and `description` as the brief. Ship it in your folder to give Copilot a head start. Keep the section headings exactly as Caspian writes them (`## Brief`, `## Decisions`, `## Current state`, `## Change log`, `## Notes`), or the app flags the file and stops writing to it until it's fixed. For projects Caspian builds, `versions/` also holds their version history.

`database.json` holds the people who created an account in the project, and only exists for a project that [has accounts](#accounts). Like `versions/`, it sits outside `dist/`, so it is never served to a page - see [database.json](#database).

## The manifest (canvas.json)

Every canvas folder needs a `canvas.json` at its root:

```json
{
  "id": "my-canvas",
  "name": "My Canvas",
  "version": "1.0.0",
  "author": "you",
  "apiVersion": "1",
  "renderer": "renderer.html",
  "description": "What this canvas does.",
  "schema": {},
  "capabilities": ["read-frontmatter", "create-notes"]
}
```

| Field | Notes |
| --- | --- |
| `id` | Unique, stable identifier. Becomes part of the canvas's storage folder name and its `canvas://` URL - don't change it after people have created instances of it. |
| `name` | Display name shown in Caspian. |
| `version`, `author` | Free-form metadata, shown to the user before they approve the canvas. |
| `apiVersion` | Must match the host's bridge contract version, currently `"1"`. A mismatch is rejected outright with an explicit "needs an update" error rather than failing silently later. |
| `renderer` | Entry file inside `dist/` that gets loaded into the iframe, e.g. `renderer.html`. |
| `description` | Optional, shown alongside `name`. |
| `schema` | Reserved for future host-driven configuration UI. Leave it `{}` for now. |
| `capabilities` | Which bridge methods this canvas is allowed to call - see below. The user approves the full list once, the first time they open an instance of it. |
| `features` | Optional. Visitor-facing controls a publisher can switch off for a published copy - see [publisher-toggleable features](#features). |
| `pages` | Optional. The canvas's pages when it has more than one, and which ones need a signed-in user - see [Pages](#pages). Without it, the canvas is one page: `renderer`. |
| `publishedSidecar` | Optional. `"split"` publishes only the sidecar's `published` section and shows each visitor only what they may see - see [Keeping data private when published](#split-sidecar). Default `"whole"`: any visitor can read the whole sidecar. |

A machine-readable version of this table is published as a JSON Schema at [`canvas.schema.json`](./canvas.schema.json), for anyone (or any agent) validating a `canvas.json` file programmatically.

## Capabilities

Approval is granted per canvas *type*, not per instance - approving one instance approves every current and future instance of that same `id`. Each bridge method requires one of these, or none at all:

| Capability | Gates |
| --- | --- |
| `read-frontmatter` | `note.getMeta`, `note.list` |
| `write-frontmatter` | `note.setMeta` |
| `create-notes` | `anchor.create` |
| `delete-notes` | `anchor.delete` |
| `canvas-sidecar` | `sidecar.read`, `sidecar.write`, `asset.import`, `asset.url` |
| `backend` | `backend.call` - see [Backends](#backends). Always gets its own explicit approval prompt, since a backend is unsandboxed code. |
| `users` | `auth.register`, `auth.signIn`, `auth.signOut`, `auth.currentUser` - see [Accounts and sign-in](#accounts). Only works with the project's "User registration" setting on. |
| `spaces` | `space.*` - see [Spaces](#spaces). Needs `users` too. |
| `network` | Declare this if your renderer makes outbound network requests (fetch, embeds) - surfaced to the user at approval time. |
| `bundled-assets` | Declare this if your canvas ships its own heavy bundled assets (fonts, models, textures) rather than everything the user pins. |
| `camera` | Live camera video with `navigator.mediaDevices.getUserMedia({ video: true })` - see [Using the camera](#camera). Video only; the user is asked to Allow or Block once per canvas each time the app runs. |

`anchor.click`, `host.prompt`, `pick.report`, `page.list`, and `page.open` need no capability - every canvas can always resolve its own anchors. Calling a method without the capability for it throws on the bridge call rather than failing silently.

## Publisher-toggleable features

When a canvas is published to the web (Caspian Pro), the person publishing gets a checklist of the canvas's declared `features` and can switch any of them off for visitors - a "Reset view" button, an info panel, an address bar, a "pull apart" slider. Each entry is a small object in `canvas.json`:

```text
"features": [
  {
    "id": "reset-view",
    "label": "Reset view button",
    "description": "Button that returns the camera to its default framing."
  }
]
```

Inside the Caspian app every feature is always on. In a published copy, the ids the publisher turned off are injected into the page as `window.__CASPIAN_DISABLED_FEATURES__` (a `string[]`). Your renderer reads that through the `featureEnabled()` helper from [`sdk/features.ts`](./sdk/features.ts) and, when a feature is off, simply doesn't build that control - holding it at its default state:

```ts
import { featureEnabled } from './sdk/features'

if (featureEnabled('reset-view')) {
  // create and wire up the Reset view button
}
```

`features` is entirely optional; a canvas that declares none is published as-is. It's tagged by the canvas author, never inferred at publish time.

## The bridge API

A canvas's renderer talks to the host with `postMessage`, wrapped by the tiny SDK described below. Every call is scoped to your own canvas instance - you address notes by id, never by filesystem path, and you can never reach a note owned by a different canvas.

| Method | Params | Result |
| --- | --- | --- |
| `anchor.click` | `{ id }` | `{ note }` - opens the note (or `null` if it isn't yours) |
| `anchor.create` | `{ id?, initialFrontmatter, title? }` | `{ note }` - host generates `id` if omitted |
| `anchor.delete` | `{ id }` | `{ ok: true }` |
| `note.getMeta` | `{ id }` | `{ frontmatter }` |
| `note.setMeta` | `{ id, patch }` | `{ frontmatter }` |
| `note.list` | none | `{ notes }` |
| `sidecar.read` | none | `{ data }` |
| `sidecar.write` | `{ data }` | `{ ok: true }` |
| `asset.import` | `{ kind?: 'image' \| 'model' }` | `{ assetId, url } \| null` - opens a native file picker |
| `asset.url` | `{ assetId }` | `{ url }` |
| `backend.call` | `{ fn, args? }` | `{ result }` - calls a function on the canvas's local backend |
| `host.prompt` | `{ title, body?, placeholder?, secret?, confirmLabel? }` | `{ value }` - `value` is `null` if cancelled |
| `pick.report` | `{ label, noteId? }` | `{ ok: true }` - tells Copilot what the user clicked while picking (see [below](#pick)) |
| `page.list` | none | `{ pages, current }` - the canvas's pages and the one showing (see [Pages](#pages)) |
| `page.open` | `{ id }` | `{ ok: true }` - goes to another page |
| `auth.register` | `{ email, password, name?, fields? }` | `{ user }` - creates an account and signs it in (see [Accounts](#accounts)) |
| `auth.signIn` | `{ email, password }` | `{ user }` |
| `auth.signOut` | none | `{ ok: true }` |
| `auth.currentUser` | none | `{ user }` - `null` when nobody is signed in |
| `auth.verifyEmail` | `{ code }` | `{ user }` - confirms the email with the code sent to it (published sites) |
| `auth.resendVerification` | none | `{ ok: true }` - emails a new code |
| `space.*` | see [Spaces](#spaces) | create, list, read and write groups your visitors run |

The host also pushes events you can subscribe to:

| Event | Payload | When |
| --- | --- | --- |
| `note.changed` | none | Any note in the Workbench changed - a common pattern is just reloading. |
| `anchor.selected` | `{ id }` | "Go to file" wants this canvas to reveal a specific anchor. |
| `capabilities` | `{ capabilities }` | The approved capability list changed. |
| `pick.mode` | `{ active }` | The user turned Copilot's "mark something on the canvas" pencil on or off - see [below](#pick). |
| `canvasBackendStatus` | `{ status, message? }` | Your backend's state changed: `'installing'`, `'running'`, `'crashed'`, or `'error'`. |
| `auth.changed` | `{ user }` | Someone signed in or out - including from Caspian's Database tab. |

## Sidecar data

Anything a Caspian canvas needs to remember about a specific instance - pin coordinates, camera position, a loaded file's asset id - goes through `sidecar.read` / `sidecar.write`. The shape is entirely up to you; the host stores whatever you send as opaque JSON, scoped to that one instance, and hands it back unchanged next time the canvas opens. This requires the `canvas-sidecar` capability.

## Backends

A canvas that needs real server-side code or a database can ship a backend in a `backend/` folder: Node (`index.js` and `package.json`) or Python (`main.py` and `requirements.txt`). Caspian installs its dependencies, with a private `.venv` for Python, and runs it as a local process for that project.

The renderer calls it with `backend.call` and the `backend` capability. A project with a backend can be published: Caspian also uploads the project folder (the backend's source and the data files it works on, never `node_modules`, `.venv`, caches, notes or the local user database), and the published site runs a Node.js backend in each visitor's browser over those files. A visitor's changes stay in their own browser. "Clone to my Caspian" copies the backend too, and its dependencies install the first time it starts, after the usual approval.

Caspian supplies the process harness itself. Register handlers with `caspian.handle()`, which is already in scope - don't import it. In Node (CommonJS, no `import`/`export`):

```text
// backend/index.js
caspian.handle('listOrders', async (args) => {
  // full Node.js; backend/ is the working directory, so keep data files there
  return { orders: [] }   // must be JSON-serializable
})
```

Or in Python (3.8-compatible; handlers may be plain or `async def`):

```text
# backend/main.py
@caspian.handle('listOrders')
def list_orders(args):
    # args is whatever the renderer passed, or None
    return {"orders": []}   # must be JSON-serializable
```

The renderer side is the same for both:

```ts
const { result } = await callBridge('backend.call', { fn: 'listOrders', args: { page: 1 } })
```

The process starts on the first `backend.call` and stays running, so module-level state (an open database handle, a cache) lasts for the life of the app. Anything a Python backend prints goes to a log, never to the renderer. Subscribe to `canvasBackendStatus` to show install progress or a crash.

> **Currently limited to projects Caspian builds.** Backends run for projects made *From a Prompt* (and ones made earlier from an API or a public repo). A `backend/` folder inside a canvas you install yourself with *I'll write my own project* isn't run yet, so for now a hand-written canvas should keep its state in the [sidecar](#sidecar).

## Working with assets

To let someone load a file into a canvas - a photo, a `.glb` model - call `asset.import`. It opens a native file picker, copies the chosen file into that instance's own storage, and returns an `assetId` plus a `url` you can point an `<img>` or `<model-viewer>` at directly. Your renderer never sees a real filesystem path. Once imported, resolve the same asset again later with `asset.url`.

## Using the camera

With the `camera` capability, a canvas can show live video from the computer's camera. Call `getUserMedia` from your renderer and play the stream in a muted, inline `<video>`:

```ts
try {
  const stream = await navigator.mediaDevices.getUserMedia({ video: true })
  videoEl.srcObject = stream   // <video autoplay playsinline muted>
} catch (err) {
  // NotAllowedError: the user chose Block. NotFoundError: no camera.
  showMessageWithRetry(err)
}

// when you're done with it
stream.getTracks().forEach((t) => t.stop())
```

The first time a canvas asks, Caspian shows an Allow / Block prompt with the canvas's name, and remembers the answer until the app quits. Audio is always refused. A canvas with this capability is served from its own origin, so no other canvas can share its camera access.

## Asking for required info (e.g. an API key)

Some canvases can't do anything useful until the user gives them something first - an API key for a third-party service is the common case. You might reach for the browser's own `window.prompt()` here, but it won't work: a canvas's iframe is sandboxed with `allow-scripts` only, and browsers disable native dialogs (`prompt`, `alert`, `confirm`) inside a sandboxed frame that lacks `allow-modals`. Building your own `<input>` overlay inside your canvas works, but a value like an API key deserves a dialog the user can trust wasn't drawn by arbitrary third-party canvas code - and every canvas re-implementing that dialog from scratch is wasted effort.

`host.prompt` solves both problems: it's answered by a dialog the **trusted host itself** renders, outside your iframe entirely, always labeled with your canvas's name so it can never be mistaken for a message from Caspian itself.

```ts
const { value } = await callBridge('host.prompt', {
  title: 'Connect to Some API',
  body: 'Get a free API key at example.com, then paste it below.',
  placeholder: 'API key',
  secret: true,          // masks the input, like a password field
  confirmLabel: 'Connect'
})
if (!value) return       // user cancelled

// stash it in your own sidecar so you don't have to ask again next time
await callBridge('sidecar.write', { data: { ...existingSidecarData, apiKey: value } })
```

`host.prompt` needs no capability - like `anchor.click`, it works even before the user has approved your canvas, since it touches no Workbench data on its own. Where you actually *use* the value it returns (an outbound request with that key, say) is on you, same as anything else your renderer does.

## Letting Copilot point at things

Copilot's chat has a pencil button for "mark something on the canvas", so the user can click the thing they mean instead of describing it. When they turn it on, the host sends a `pick.mode` event with `{ active: true }`. While it's active, clicks on anything meaningful in your canvas should call `pick.report` instead of doing their usual action. Stay in picking mode after a report: the user can mark several things before sending their message. When `{ active: false }` arrives (pencil again, Escape, or message sent), go back to normal clicks.

```text
let picking = false
onBridgeEvent('pick.mode', ({ active }) => { picking = active })

pinEl.addEventListener('click', () => {
  if (picking) {
    void callBridge('pick.report', { label: pin.title, noteId: pin.noteId })
    return
  }
  void callBridge('anchor.click', { id: pin.noteId })
})
```

`label` is a short, readable name for what was clicked (a row's name, a pin's title, a 3D part, or "the current view" if nothing more specific fits). It's shown to the user as a chip and given to the model word for word. Pass `noteId` only when the element has exactly one note attached, so Copilot can read that note too. `pick.report` needs no capability. Supporting it is optional, but without it the pencil does nothing on your canvas.

## Pages

A canvas is one page by default - `dist/<renderer>`. When it needs separate screens people move between (a public landing page and a members' area, say), list its pages in `canvas.json`. Each page is its own HTML file in `dist/`:

```text
"pages": [
  { "id": "home",    "title": "Home",       "file": "renderer.html" },
  { "id": "members", "title": "Members",    "file": "members.html", "requiresAuth": true },
  { "id": "login",   "title": "Sign in",    "file": "login.html",   "signIn": true }
]
```

| Field | Notes |
| --- | --- |
| `id` | Stable kebab-case id, used by `page.open`. |
| `title` | Shown to people, for example on the sign-in page. |
| `file` | The page's HTML file in `dist/`. The page whose file is `renderer` is the one the project opens on; Caspian adds it if you leave it out. |
| `requiresAuth` | Optional. Only a signed-in user may open this page - see [Accounts and sign-in](#accounts). |
| `signIn` | Optional, at most one page. Your own sign-in page, where people are sent when they open a `requiresAuth` page signed out. |

Go between pages with `callBridge('page.open', { id: 'members' })` or a plain `<a href="members.html">` - both get the same sign-in check. `page.list` returns every page plus the `current` one. Every page is a fresh document, so it reads what it needs (from the sidecar, say) when it loads; nothing kept in JavaScript variables survives a page change. Each page loads the [bridge SDK](#helpers) itself.

## Accounts and sign-in

A project with **User registration** on can let people create an account and sign in. The setting is a checkbox in *Create a new project*, off by default, and can be changed any time in the project's **Project Settings** tab. Copilot can also turn it on for a change that needs accounts, but only after you click *Turn on User registration* on its proposal. A canvas that uses accounts declares the `users` capability:

```ts
// sign up (also signs the new account in)
const { user } = await callBridge('auth.register', {
  email, password, name,
  fields: { company: 'Acme' }   // any extra sign-up fields from database.json
})

// sign in / out, and who is signed in now
await callBridge('auth.signIn', { email, password })
await callBridge('auth.signOut', {})
const { user } = await callBridge('auth.currentUser', {})   // null when signed out

onBridgeEvent('auth.changed', ({ user }) => { /* update the UI */ })
```

A `user` is `{ id, email, name, createdAt, fields }` - never the password. Caspian stores accounts and hashes passwords itself, so never keep users or passwords in the sidecar. Emails are unique and stored lower-case; passwords need at least 8 characters. A failed call rejects with a message that's safe to show as-is, and a wrong email or password always gets the same one.

**Sign-in-only pages.** Mark a page `"requiresAuth": true` and Caspian won't serve it to anyone who isn't signed in. It sends them to your `signIn` page with `?next=<page id>`, or to Caspian's own sign-in and create-account page if you don't have one. After a successful `auth.signIn` or `auth.register`, call `page.open` with that `next` id. The check runs in Caspian, where the page is served, so neither a link nor a page's own script can skip it. Keep each person's data in the sidecar under their `user.id`.

> **No `<form>` submits.** The canvas iframe is sandboxed without `allow-forms`, so a form's `submit` event never fires. Use a `<button type="button">` with a click handler, and handle Enter in the inputs yourself.

**Inside Caspian**, accounts live in the project's own [`database.json`](#database), and one person at a time can be signed in to a project - you, trying it out. The **Database** tab (under Context in the right sidebar, shown when User registration is on) lists the accounts, shows who is signed in, and can sign them out or remove an account. **On a published site**, visitors' accounts are kept on Caspian's server, and the same pages need sign-in there. They stay in [two-way sync](#sync) with the project's `database.json`, so the Database tab shows one list, and marks anyone who hasn't confirmed their email on the live site yet. A published site's sidecar is still read-only, so per-person data can be read there but not yet saved.

**Confirming an email.** Anyone can register with any address, so on a published site a new account gets a 6-digit code by email, and `user.emailVerified` stays `false` until it's entered with `auth.verifyEmail({ code })`. `auth.resendVerification` sends a new code. The code lasts 30 minutes and allows five tries. Inside Caspian there is no email step, and every account counts as confirmed.

With User registration off, the `auth.*` calls are refused and `requiresAuth` pages show a "page unavailable" notice instead.

## database.json

A project's accounts, next to its `canvas.json`. It's optional: Caspian creates it when the first person signs up. Ship one yourself only to ask for more at sign-up than email, password and name:

```json
{
  "caspian_database": 1,
  "users": {
    "fields": [
      { "id": "company", "label": "Company", "type": "string", "required": true },
      { "id": "newsletter", "label": "Send me updates", "type": "boolean" }
    ],
    "records": []
  }
}
```

| Field | Notes |
| --- | --- |
| `caspian_database` | Format version, currently `1`. A newer version than the app understands is refused, never overwritten. |
| `users.fields` | Extra sign-up fields: `id` (lower-case, `_` or `-`), `label`, `type` (`string`, `number`, `boolean`, `email`, `phone`, `url`, `date`) and optional `required`. Pass values as `auth.register`'s `fields`; Caspian's own sign-in page asks for them too. |
| `users.records` | Written by Caspian only: `{ id, email, name, passwordHash, createdAt, updatedAt, emailVerified, fields }`. Passwords are stored as hashes, never as text: scrypt for accounts made in Caspian, PBKDF2 for ones [synced down](#sync) from the live site. `emailVerified` is whether they confirmed their email on the live site. |

The file is never served to a page. Re-installing your folder with *I'll write my own project* keeps the installed accounts and takes `users.fields` from your folder. *Make a copy* keeps the fields but starts with no accounts. A JSON Schema is published at [`database.schema.json`](./database.schema.json).

## Keeping data private when published

A published copy normally gets the whole sidecar, and any visitor can read it: it's embedded in the page. That's fine for a map or a 3D model, but not for records about people. Set `"publishedSidecar": "split"` in `canvas.json` and keep a `published` section in your sidecar holding only what may be shown:

```json
{
  ...your own data (never published)...,
  "published": {
    "public":  { ... },                       // everyone
    "members": { ... },                       // any signed-in visitor
    "byEmail": {
      "ada@example.com": { ... }              // only Ada, once her email is confirmed
    }
  }
}
```

Publishing then uploads only `published`. On the live site, `sidecar.read` returns `{ data: { public, members, own }, published: true }`. `members` is `null` until someone signs in. `own` is that visitor's own `byEmail` entry, matched by their lower-cased email, and only once they've [confirmed it](#accounts). Only `public` is embedded in the page. The rest is fetched per visitor, and "Clone to my Caspian" gets only `public`. Inside Caspian, `sidecar.read` still returns your whole sidecar, so a page checks `published` to know which it has.

Recompute `published` whenever you save, from the same data, and keep other people's names and contact details out of `public` and `members`. It needs the `users` capability and User registration for anything beyond `public`.

## Spaces: groups your visitors run

Some canvases aren't one person's tool but a product many groups use: one site where each road association, club or team keeps its own records. A **space** is one such group. Any signed-in visitor with a confirmed email can create one and becomes its **admin**. Declare the `spaces` capability (with `users`, and User registration on).

Each space has four parts, and who can read each one is enforced by Caspian, not your code:

| Part | Who can read it | For example |
| --- | --- | --- |
| `data` | The space's admins | The full ledger |
| `public` | Anyone, by the space's `slug` | The association's own page |
| `members` | Admins and members | Vote totals, chosen contractors |
| `byEmail` | Each person only their own entry | An owner's balance and payments |

Only admins can write. A **member** is anyone whose confirmed email is a key in `byEmail`, so people join by signing up with the email the admins already have for them, with no invitation. Admins are a list of emails: add a co-admin with `space.addAdmin`, and they're in as soon as they sign in with that address (on a published site they're emailed an invitation).

```ts
const { space } = await callBridge('space.create', { name: 'Birch Lane Road Association' })
// space = { id, slug: 'birch-lane-road-association', name, role: 'admin', createdAt }

await callBridge('space.write', {
  id: space.id,
  data: ledger,                                   // admins only
  public: { tagline: 'Plowed since 1978' },       // anyone
  members: { votes },                             // admins and members
  byEmail: { 'ada@example.com': { balance: 12 } } // Ada only
})

const { spaces } = await callBridge('space.list', {})       // yours, with your role in each
const view = await callBridge('space.read', { id })          // { space, data, public, members, own }
const page = await callBridge('space.public', { slug })      // anyone: { space, public }
```

| Method | Params | Result |
| --- | --- | --- |
| `space.create` | `{ name }` | `{ space }` - you're its admin |
| `space.list` | none | `{ spaces }` - where you're an admin or member |
| `space.read` | `{ id }` | `{ space, data, public, members, own }` - `data` is `null` unless you're an admin; `own` is your `byEmail` entry |
| `space.write` | `{ id, name?, data?, public?, members?, byEmail? }` | `{ ok: true }` - admins; parts left out stay as they are |
| `space.public` | `{ slug }` | `{ space, public }` - anyone, even signed out |
| `space.admins` | `{ id }` | `{ admins: [{ email, joined, you }] }` - admins |
| `space.addAdmin` / `space.removeAdmin` | `{ id, email }` | `{ ok: true }` - admins; a space always keeps one admin |
| `space.delete` | `{ id }` | `{ ok: true }` - admins |

A space holds up to 2 MB, and one person can create up to 10. Carry a space in your page addresses (`association.html?a=<slug>`, for example): a published site passes its own address's query on to the canvas, so a shared link like `https://yoursite.sites.caspian.md/?a=birch-lane` reaches the page. Inside Caspian, spaces are kept in the project's own `spaces.json` (next to `database.json`, never published) so you can build and try everything before publishing. On a published site they live on Caspian's server, in [two-way sync](#sync) with `spaces.json`, and the Database tab lists them and can delete one.

## Two-way sync with the live site

Once a project with User registration is published, its accounts (`database.json`) and spaces (`spaces.json`) are kept in step with the live site's, both ways. A project syncs when you open it, a few seconds after anything changes in it, every couple of minutes while it's open, and after each publish. The Database tab shows when it last synced and has a **Sync now** button. Syncing needs you signed in to your Caspian account.

| What happened since the last sync | Result |
| --- | --- |
| Changed on one side | That side's version wins |
| Changed on both | The newer change wins (by `updatedAt`) |
| Deleted on one side | Deleted on the other, unless it was changed there since |
| New on one side | Copied to the other |
| The same email signed up on both sides | The live site's account wins; the one made in Caspian is dropped, and the Database tab says so |

Accounts made in Caspian become real accounts on the live site, with the same password, but unconfirmed: they confirm their email the first time they sign in there, so a test account can't read anyone's records. A record changed on your computer while a sync is running is kept and sent next time.

> **You can see everything.** As the project's owner, your copy holds every account (with password hashes, never passwords) and every space's data, including the parts only a space's admins see on the site. If people other than you keep records in your project, tell them so, for example on the sign-up page.

## Shared helpers

The SDK is a couple of small, dependency-free files you copy into your own `sdk/` folder and bundle directly into your renderer (they aren't loaded at runtime from anywhere) - source below, or copy-paste from the links:

```ts
import { callBridge, onBridgeEvent } from './sdk/bridge'
import { theme } from './sdk/theme'          // Caspian's own color tokens, so your canvas can match
import { pulse } from './sdk/flash'          // on/off timing driver for "Go to file" highlight effects
import { featureEnabled } from './sdk/features' // only if your canvas.json declares "features"
```

None of these are required - `bridge.ts` is the only one that actually talks to the host. `theme`, `pulse`, and `featureEnabled` just save you from re-deriving Caspian's palette, its flash-on-select timing, and the published-feature-toggle protocol from scratch.

| File | What it's for |
| --- | --- |
| [`sdk/bridge.ts`](./sdk/bridge.ts) | The `callBridge` / `onBridgeEvent` transport described above. |
| [`sdk/types.ts`](./sdk/types.ts) | Shared bridge/manifest types `bridge.ts` imports from - copy it alongside. |
| [`sdk/theme.ts`](./sdk/theme.ts) | Caspian's color tokens, for a canvas that wants to visually match the host. |
| [`sdk/flash.ts`](./sdk/flash.ts) | The on/off timing driver used for "Go to file" highlight effects. |
| [`sdk/features.ts`](./sdk/features.ts) | The `featureEnabled()` check for [publisher-toggleable features](#features). Only needed if you declare `features`. |

## Build & install

Bundle your renderer with whatever you like (esbuild, Vite, a plain script tag) into a `dist/` folder next to your `canvas.json`. There's no required build tool - Caspian only cares that `dist/<renderer>` exists and loads standalone, with no server-side dependencies.

Once it's built, install it without publishing anything:

1. In Caspian, open **Create a new project**.
2. Choose **I'll write my own project**.
3. Name the instance, then pick your canvas's folder (the one containing `canvas.json`).
4. Caspian validates the manifest, copies the folder in, and opens it - ready to approve capabilities and start pinning.

Re-selecting the same folder later (say, after your build pipeline regenerates it) replaces the installed copy in place, so iterating is just: build, re-pick the folder.

> **Re-installing replaces `versions/` too.** The installed copy is swapped out for your folder as-is, so edits made in Caspian to Context are lost unless your folder carries the same file. To keep them, copy the installed project's `versions/` back into your folder before re-picking it. Installed projects live inside your Workbench, in `.caspian/canvas-types/<id>`, so they move and back up with the folder.

## Minimal example

The smallest possible canvas - one button per note, clicking it opens the note, reloading on any change:

```ts
// renderer.ts
import { callBridge, onBridgeEvent } from './sdk/bridge'

async function main() {
  const { notes } = await callBridge('note.list', {})

  const root = document.getElementById('root')!
  for (const note of notes) {
    const btn = document.createElement('button')
    btn.textContent = note.title
    btn.onclick = () => void callBridge('anchor.click', { id: note.id })
    root.appendChild(btn)
  }

  onBridgeEvent('note.changed', () => location.reload())
}

void main()
```

```json
// canvas.json
{
  "id": "hello-world",
  "name": "Hello World",
  "version": "1.0.0",
  "author": "you",
  "apiVersion": "1",
  "renderer": "renderer.html",
  "schema": {},
  "capabilities": ["read-frontmatter"]
}
```

Bundle `renderer.ts` into `dist/renderer.js`, reference it from a `dist/renderer.html` with a `<div id="root"></div>`, and that folder is a complete, installable canvas.

## Versioning and stability

The bridge contract currently ships as `apiVersion: "1"`. Every canvas declares which version it was built against, and the host checks it before ever loading the canvas's files: a mismatch is rejected outright with an explicit "needs an update" error, never a silent failure at some later, harder-to-debug point.

If the bridge contract ever needs a breaking change, `apiVersion` will bump, and a canvas still declaring the old version will stop loading with that same explicit error until it's updated. There's no deprecation window today - old and new versions aren't guaranteed to keep working side by side across a bump - though that's something we may add later.
