Validating and publishing canvases
A canvas's source lives in PostHog, versioned per publish. Publishing is guarded: every edit is
based on a specific version, and the server refuses to overwrite newer work. Every publish queues
a server-side build, and the canvas renders the last successful build.
The source project
canvas-source-retrieve returns:
project — schemaVersion (1), files (path → content), entryHtml ("index.html"),
dependencies (exact platform-pinned versions), canvasSdkVersion, capabilities.
current_version_id — the version your edits are based on. Keep it; the publish needs it.
It is null for a canvas that has never been published — pass that null on the first publish.
Keep index.html and dependencies exactly as returned. You may add relative source files and
admitted assets to the project. Use ?worker for a self-contained module worker and represent
binary assets as base64 entries in assets; new npm dependencies or dependency-version drift fail
validation.
Declare capabilities
The host enforces project.capabilities at runtime, so an undeclared ph call builds fine and
then dies in the rendered canvas. Declare:
capabilities.posthog.insights — every insight short id the canvas passes to ph.loadInsight.
capabilities.posthog.captureEvents — every event name it passes to ph.capture.
capabilities.posthog.inlineQueries: true — when it calls ph.query at all.
capabilities.posthog.agentRequests: true — when it calls ph.agent.request.
capabilities.connectors — one { "provider", "tools" } entry per third-party provider the
canvas reads through ph.connectors.call, listing every tool it calls on that provider. A
provider is a native id (github) or mcp:<server host> (mcp:mcp.calendly.com). Unknown
providers, unregistered native tools, and private MCP hosts fail validation; every declared
tool must have is_read_only: true in the catalog. An upstream hint alone does not grant access.
A canvas with connectors cannot declare shared state.
capabilities.network.origins — each exact HTTPS origin used by fetch, XMLHttpRequest, or an
external stylesheet, image, font, media file, or frame. Remote scripts remain blocked.
Do not include paths, credentials, queries, fragments, or wildcards. The host must be public:
loopback and private IPs, single-label names like intranet, and the .local, .localhost,
.internal, and .home.arpa suffixes are all rejected, so a local dev host such as
https://localhost:8010 fails validation with an invalid_network_origin error. Data sent to a
declared origin leaves PostHog and appears in the capability review before promotion.
Before validation, inventory every literal external URL in every source file. Classify navigation
links and ph.openExternal() URLs as navigation; they do not need a network origin. For every
request or resource URL, declare its scheme + host + optional port only. Include every origin a
request redirects to and every secondary origin a stylesheet references for fonts or images.
Never infer that one CDN hostname covers another.
Validation rejects undeclared literal calls and resource URLs (capability_missing_* diagnostics)
so you can fix them before publishing. Dynamic URLs and redirect destinations cannot be inferred,
so the inventory is still required even when validation is clean.
Validate until clean
canvas-validate-create is side-effect free; call it as often as needed.
Diagnostics carry severity, a stable code, a message, and (for file-specific problems)
path and line:
error diagnostics block publishing — fix all of them. Common ones: import_not_allowed
(bare imports are limited to the dependencies returned in the source project),
forbidden_dynamic_import / forbidden_require / forbidden_inline_script,
invalid_path, capability_missing_insight / capability_missing_capture_event /
capability_missing_inline_queries / capability_missing_agent_requests /
capability_missing_network_origin,
dependency_not_admitted / dependency_version_mismatch, platform_token_redeclared (a CSS
variable named like a Quill token that the platform stylesheet sets on every element, so the
value never applies; prefix your own variables), and path/size violations.
warning diagnostics don't block, but heed them: network_fetch / network_xhr mean the code
reaches for the network directly. Declare the exact HTTPS origin or use the ph bridge.
Publish guarded
Publishing goes live immediately and is the default way to save a change, for a canvas's first
version and for every follow-up edit. Every version records who published it and which task did
the work, so the history stays reviewable after the fact. Stage a draft instead only when the user
asked for a draft, a preview, or a review step — see "Draft, then promote" below.
Two ways to publish, both guarded:
- Whole project —
canvas-publish-create with the complete project.
- Per-file edits —
canvas-edit-create with operations (each sets a
file's complete content, or deletes it with content: null). Prefer this for small changes to a
large project; the guard is mandatory here because a diff's meaning depends on its base.
For a whole-project publish with canvas-publish-create:
- Always pass
expected_current_version_id — the current_version_id you read (or explicit
null on a first publish). Unguarded publishes can silently clobber concurrent edits.
- Include a short
prompt describing the change; it becomes the version-history entry's label.
- Pass
name only to rename the canvas (e.g. a first build of an untitled canvas).
- Publish once per requested change. If the user asks for another edit afterwards, re-read the
source (the head may have moved) and publish again — don't batch unrelated changes into one
version, and don't publish work-in-progress after every micro-edit.
- A 429 means the team's build capacity is temporarily exhausted. Wait ~30 seconds and retry the
same publish; nothing was saved.
The response returns the new current_version_id.
After publishing: wait for the build
A publish queues a server-side build of the version. The canvas does not update until the build
is ready, and nobody else is watching the result — you own it. Poll canvas-builds-retrieve
every few seconds (up to ~2 minutes) until the build you queued is terminal:
queued/building — in progress; poll again shortly.
ready — the canvas's published_build_id advances to this build (unless a newer publish
superseded it first). The task's canvas work is done.
failed — read the build's error diagnostics, fix the project, and publish again. A failed
build never replaces the last good one, so the canvas keeps rendering the previous version —
finishing the task here would leave the user with a stale canvas and a silent failure.
Runtime error reports (filed on the authoring task when a rendered canvas throws) name the build
they came from. A report from an older build id is history, not evidence about your current
code — check it against the build you just published before acting on it. In particular, a report
that a documented ph API is undefined (e.g. ph.state) means that artifact was baked by an
older host runtime: republish so a current build replaces it. Never "fix" it by removing the API
or its capability declaration.
Draft, then promote
Publishing goes live the moment its build is ready, and that is the default. Use a draft only
when the user asked for one: a preview to look at first, a review step before going live, or an
explicit "don't publish yet". A draft is a real, buildable version that is never the head: the
live canvas keeps rendering the current version until someone promotes the draft. This is
different from canvas-validate-create, which only compile-checks and produces no build or
preview.
- Stage —
canvas-draft-create with the complete project (same shape, capabilities, and
validation as a publish). No expected_current_version_id: a draft is based on nothing and
conflicts with nothing. The response returns the draft's version_id, its queued build, and
capability_widening — the insights, capture events, inline queries, and network origins the
draft declares beyond the live version. Surface a non-empty widening to the user before
promoting; it is the access the change would newly grant.
- Wait for the build — poll
canvas-builds-retrieve until the draft's build is terminal, the
same way you would after a publish. A failed draft build is fixed by staging a new draft, not by
promoting.
- Preview — read the draft's files with
canvas-source-retrieve passing its version_id; once
its build is ready the app renders that draft when the version is opened. The draft is not
in canvas-versions-retrieve (that lists published history only) and cannot be reverted onto —
list pending drafts with canvas-drafts-retrieve.
- Promote — when the user approved the draft or asked to go live; a draft the user asked to
review stays staged until they say so.
canvas-promote-create makes the draft the live head.
Pass
expected_current_version_id (the live current_version_id from canvas-source-retrieve); it
is guarded exactly like a publish and 409s on a moved head (recover as below). A draft whose
build is still ready goes live with no rebuild; otherwise a fresh build is queued, so wait for
it as in step 2. Promote is the only path from a draft to live.
Recovering from 409 version_conflict
A 409 means the canvas moved past your base — a concurrent publish or a revert. The response
includes the live current_version_id. Never retry unguarded to force your version through:
- Re-read the source with
canvas-source-retrieve.
- Re-apply your edits to the fresh source (the new head may contain someone else's changes —
preserve them).
- Publish again with the new
current_version_id.
Version history semantics
Each publish appends a full source version and moves the head pointer; users can revert to older
versions in the app (which republishes and rebuilds them). The guard matters because basing your
publish on the version you actually read is what keeps a user's revert, another agent's publish,
and your edit from silently erasing each other.
1---2name: validating-and-publishing-canvases-23description: Validate and publish a canvas source project safely: the source-project shape, declared capabilities, reading the current version pointer, iterating on validation diagnostics, guarded publishing with expected_current_version_id, staging a draft build and promoting it, waiting out the queued build, and recovering from a 409 version_conflict or a 429 capacity limit without overwriting concurrent work. Use whenever a canvas edit is ready to save, a draft build is wanted, a canvas publish or build returns diagnostics or a conflict, or a task needs to understand canvas version history.4---56# Validating and publishing canvases78A canvas's source lives in PostHog, versioned per publish. Publishing is guarded: every edit is9based on a specific version, and the server refuses to overwrite newer work. Every publish queues10a server-side build, and the canvas renders the last successful build.1112## The source project1314`canvas-source-retrieve` returns:1516- `project` — `schemaVersion` (1), `files` (path → content), `entryHtml` (`"index.html"`),17 `dependencies` (exact platform-pinned versions), `canvasSdkVersion`, `capabilities`.18- `current_version_id` — the version your edits are based on. Keep it; the publish needs it.19 It is `null` for a canvas that has never been published — pass that `null` on the first publish.2021Keep `index.html` and `dependencies` exactly as returned. You may add relative source files and22admitted assets to the project. Use `?worker` for a self-contained module worker and represent23binary assets as base64 entries in `assets`; new npm dependencies or dependency-version drift fail24validation.2526## Declare capabilities2728The host enforces `project.capabilities` at runtime, so an undeclared `ph` call builds fine and29then dies in the rendered canvas. Declare:3031- `capabilities.posthog.insights` — every insight short id the canvas passes to `ph.loadInsight`.32- `capabilities.posthog.captureEvents` — every event name it passes to `ph.capture`.33- `capabilities.posthog.inlineQueries: true` — when it calls `ph.query` at all.34- `capabilities.posthog.agentRequests: true` — when it calls `ph.agent.request`.35- `capabilities.connectors` — one `{ "provider", "tools" }` entry per third-party provider the36 canvas reads through `ph.connectors.call`, listing every tool it calls on that provider. A37 provider is a native id (`github`) or `mcp:<server host>` (`mcp:mcp.calendly.com`). Unknown38 providers, unregistered native tools, and private MCP hosts fail validation; every declared39 tool must have `is_read_only: true` in the catalog. An upstream hint alone does not grant access.40 A canvas with connectors cannot declare shared state.41- `capabilities.network.origins` — each exact HTTPS origin used by `fetch`, `XMLHttpRequest`, or an42 external stylesheet, image, font, media file, or frame. Remote scripts remain blocked.43 Do not include paths, credentials, queries, fragments, or wildcards. The host must be public:44 loopback and private IPs, single-label names like `intranet`, and the `.local`, `.localhost`,45 `.internal`, and `.home.arpa` suffixes are all rejected, so a local dev host such as46 `https://localhost:8010` fails validation with an `invalid_network_origin` error. Data sent to a47 declared origin leaves PostHog and appears in the capability review before promotion.4849Before validation, inventory every literal external URL in every source file. Classify navigation50links and `ph.openExternal()` URLs as navigation; they do not need a network origin. For every51request or resource URL, declare its scheme + host + optional port only. Include every origin a52request redirects to and every secondary origin a stylesheet references for fonts or images.53Never infer that one CDN hostname covers another.5455Validation rejects undeclared literal calls and resource URLs (`capability_missing_*` diagnostics)56so you can fix them before publishing. Dynamic URLs and redirect destinations cannot be inferred,57so the inventory is still required even when validation is clean.5859## Validate until clean6061`canvas-validate-create` is side-effect free; call it as often as needed.62Diagnostics carry `severity`, a stable `code`, a `message`, and (for file-specific problems)63`path` and `line`:6465- `error` diagnostics block publishing — fix all of them. Common ones: `import_not_allowed`66 (bare imports are limited to the dependencies returned in the source project),67 `forbidden_dynamic_import` / `forbidden_require` / `forbidden_inline_script`,68 `invalid_path`, `capability_missing_insight` / `capability_missing_capture_event` /69 `capability_missing_inline_queries` / `capability_missing_agent_requests` /70 `capability_missing_network_origin`,71 `dependency_not_admitted` / `dependency_version_mismatch`, `platform_token_redeclared` (a CSS72 variable named like a Quill token that the platform stylesheet sets on every element, so the73 value never applies; prefix your own variables), and path/size violations.74- `warning` diagnostics don't block, but heed them: `network_fetch` / `network_xhr` mean the code75 reaches for the network directly. Declare the exact HTTPS origin or use the `ph` bridge.7677## Publish guarded7879Publishing goes live immediately and is the default way to save a change, for a canvas's first80version and for every follow-up edit. Every version records who published it and which task did81the work, so the history stays reviewable after the fact. Stage a draft instead only when the user82asked for a draft, a preview, or a review step — see "Draft, then promote" below.8384Two ways to publish, both guarded:8586- **Whole project** — `canvas-publish-create` with the complete `project`.87- **Per-file edits** — `canvas-edit-create` with `operations` (each sets a88 file's complete content, or deletes it with `content: null`). Prefer this for small changes to a89 large project; the guard is mandatory here because a diff's meaning depends on its base.9091For a whole-project publish with `canvas-publish-create`:9293- Always pass `expected_current_version_id` — the `current_version_id` you read (or explicit94 `null` on a first publish). Unguarded publishes can silently clobber concurrent edits.95- Include a short `prompt` describing the change; it becomes the version-history entry's label.96- Pass `name` only to rename the canvas (e.g. a first build of an untitled canvas).97- Publish once per requested change. If the user asks for another edit afterwards, re-read the98 source (the head may have moved) and publish again — don't batch unrelated changes into one99 version, and don't publish work-in-progress after every micro-edit.100- A 429 means the team's build capacity is temporarily exhausted. Wait ~30 seconds and retry the101 same publish; nothing was saved.102103The response returns the new `current_version_id`.104105## After publishing: wait for the build106107A publish queues a server-side build of the version. **The canvas does not update until the build108is ready, and nobody else is watching the result — you own it.** Poll `canvas-builds-retrieve`109every few seconds (up to ~2 minutes) until the build you queued is terminal:110111- `queued`/`building` — in progress; poll again shortly.112- `ready` — the canvas's `published_build_id` advances to this build (unless a newer publish113 superseded it first). The task's canvas work is done.114- `failed` — read the build's error diagnostics, fix the project, and publish again. A failed115 build never replaces the last good one, so the canvas keeps rendering the previous version —116 finishing the task here would leave the user with a stale canvas and a silent failure.117118Runtime error reports (filed on the authoring task when a rendered canvas throws) name the build119they came from. A report from an **older build id** is history, not evidence about your current120code — check it against the build you just published before acting on it. In particular, a report121that a documented `ph` API is undefined (e.g. `ph.state`) means that artifact was baked by an122older host runtime: republish so a current build replaces it. Never "fix" it by removing the API123or its capability declaration.124125## Draft, then promote126127Publishing goes live the moment its build is ready, and that is the default. Use a draft only128when the user asked for one: a preview to look at first, a review step before going live, or an129explicit "don't publish yet". A draft is a real, buildable version that is never the head: the130live canvas keeps rendering the current version until someone promotes the draft. This is131different from `canvas-validate-create`, which only compile-checks and produces no build or132preview.1331341. **Stage** — `canvas-draft-create` with the complete `project` (same shape, capabilities, and135 validation as a publish). No `expected_current_version_id`: a draft is based on nothing and136 conflicts with nothing. The response returns the draft's `version_id`, its queued `build`, and137 `capability_widening` — the insights, capture events, inline queries, and network origins the138 draft declares beyond the live version. Surface a non-empty widening to the user before139 promoting; it is the access the change would newly grant.1402. **Wait for the build** — poll `canvas-builds-retrieve` until the draft's build is terminal, the141 same way you would after a publish. A failed draft build is fixed by staging a new draft, not by142 promoting.1433. **Preview** — read the draft's files with `canvas-source-retrieve` passing its `version_id`; once144 its build is `ready` the app renders that draft when the version is opened. The draft is **not**145 in `canvas-versions-retrieve` (that lists published history only) and cannot be reverted onto —146 list pending drafts with `canvas-drafts-retrieve`.1474. **Promote** — when the user approved the draft or asked to go live; a draft the user asked to148 review stays staged until they say so. `canvas-promote-create` makes the draft the live head.149 Pass150 `expected_current_version_id` (the live `current_version_id` from `canvas-source-retrieve`); it151 is guarded exactly like a publish and 409s on a moved head (recover as below). A draft whose152 build is still `ready` goes live with no rebuild; otherwise a fresh build is queued, so wait for153 it as in step 2. Promote is the only path from a draft to live.154155## Recovering from 409 version_conflict156157A 409 means the canvas moved past your base — a concurrent publish or a revert. The response158includes the live `current_version_id`. Never retry unguarded to force your version through:1591601. Re-read the source with `canvas-source-retrieve`.1612. Re-apply your edits to the fresh source (the new head may contain someone else's changes —162 preserve them).1633. Publish again with the new `current_version_id`.164165## Version history semantics166167Each publish appends a full source version and moves the head pointer; users can revert to older168versions in the app (which republishes and rebuilds them). The guard matters because basing your169publish on the version you actually read is what keeps a user's revert, another agent's publish,170and your edit from silently erasing each other.