Build a Caspian canvas
View as MarkdownA 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.
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.
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"]
}
| 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. |
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:
| 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:
"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.
| 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.
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.
| File | What it's for |
|---|---|
sdk/bridge.ts | The callBridge / onBridgeEvent transport described above. |
sdk/types.ts | Shared bridge/manifest types bridge.ts imports from - copy it alongside. |
sdk/theme.ts | Caspian's color tokens, for a canvas that wants to visually match the host. |
sdk/flash.ts | The on/off timing driver used for "Go to file" highlight effects. |
sdk/features.ts | The 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:
- In Caspian, open Create a new canvas.
- Choose From Your Own Canvas.
- Name the instance, then pick your canvas's folder (the one containing
canvas.json). - 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.