Build a Caspian canvas

View as Markdown

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.

New to Caspian? This page is for building your own canvas. If you just want to use the app, start here instead.

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.

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:

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:

{
  "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"]
}
FieldNotes
idUnique, 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.
nameDisplay name shown in "Create a new canvas".
version, authorFree-form metadata, shown to the user before they approve the canvas.
apiVersionMust 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.
rendererEntry file inside dist/ that gets loaded into the iframe, e.g. renderer.html.
descriptionOptional, shown alongside name.
schemaReserved for future host-driven configuration UI. Leave it {} for now.
capabilitiesWhich 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.
featuresOptional. Visitor-facing controls a publisher can switch off for a published copy - see publisher-toggleable features.

A machine-readable version of this table is published as a JSON Schema at 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:

CapabilityGates
read-frontmatternote.getMeta, note.list
write-frontmatternote.setMeta
create-notesanchor.create
delete-notesanchor.delete
canvas-sidecarsidecar.read, sidecar.write, asset.import, asset.url
networkDeclare this if your renderer makes outbound network requests (fetch, embeds) - surfaced to the user at approval time.
bundled-assetsDeclare 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:

"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 and, when a feature is off, simply doesn't build that control - holding it at its default state:

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.

MethodParamsResult
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.listnone{ notes }
sidecar.readnone{ 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:

EventPayloadWhen
note.changednoneAny 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.

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:

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.

FileWhat it's for
sdk/bridge.tsThe callBridge / onBridgeEvent transport described above.
sdk/types.tsShared bridge/manifest types bridge.ts imports from - copy it alongside.
sdk/theme.tsCaspian's color tokens, for a canvas that wants to visually match the host.
sdk/flash.tsThe on/off timing driver used for "Go to file" highlight effects.
sdk/features.tsThe featureEnabled() check for publisher-toggleable 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:

// 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()
// 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.