<!--
  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 canvas → From Your Own Canvas* option.

## Folder layout

A canvas is a folder with two required members:

```text
my-canvas/
  canvas.json      <- the manifest: id, version, entry point, capabilities
  dist/            <- your built, static renderer - the only thing Caspian loads
    renderer.html
    renderer.js
  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.

## 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 "Create a new canvas". |
| `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). |

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` |
| `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. |

`anchor.click` needs 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 }` |
| `host.prompt` | `{ title, body?, placeholder?, secret?, confirmLabel? }` | `{ value }` - `value` is `null` if cancelled |

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. |

## 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.

## 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`.

## 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.

## 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 canvas**.
2. Choose **From Your Own Canvas**.
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.

## 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.
