Skip to main content
Every block renders inside a sandboxed iframe via /api/blocks/[blockId]/render. The browser hits that route, the route picks a render path based on the block’s format, and the iframe loads. This page covers what happens between request and pixel.

Two render paths

The render route dispatches on manifest.format:
Published via the in-app editor or by your agent over MCP. Your file set (entry + dependencies) is run through Vercel Sandbox, which produces a single HTML bundle. That bundle is uploaded to Convex storage and the storage id is written to blockPublishedVersions.bundleStorageId.On render, the route fetches the bundle from Convex storage and streams it as the iframe’s text/html body. Strict CSP, no cross-origin framing, no inline scripts beyond the bundled ones.

The isRenderable predicate

A block tile on a canvas is either an iframe or a placeholder. The predicate that decides which lives in canvas-host.tsx → toNodes:
This predicate must stay in sync with the render route’s fallback logic — if the node says “renderable,” the route has to be able to serve it. The render route falls back to a friendly placeholder if a block is missing its bundle or embed URL. In practice: a freshly-initialized block has neither a bundle nor an embed URL, so it renders as a placeholder tile until you publish.

Bundle storage

For code blocks, the blockPublishedVersions row carries:
  • bundleStorageId — Convex storage id pointing at the compiled HTML bundle.
  • publishedAt — when this version was cut.
  • manifest — frozen snapshot of chatblocks.json at publish time.
Canvas placements pin to a specific publishedVersionId, so updating a block doesn’t silently change what’s on the canvas. To roll forward, re-pin (or set the placement to “latest”).

Iframe sandbox

The render iframe is sandboxed at the outer layer. The platform sets the sandbox attributes; block authors don’t (and can’t) influence them. Defaults:
  • code blocks: scripts allowed, same-origin denied, top navigation denied, popups denied, forms allowed only when the forms permission is declared in the manifest.
  • embed blocks: same baseline, with the additional inner-iframe profile (scripts-only or strict) applied to the embedded site.
Outbound network requests follow the same model as a normal browser tab — but connect-src in the CSP is restricted to the origins listed in manifest.network.allowedOrigins. If your block needs to call an API, declare the network permission and list the API origin.

Live data in blocks

Blocks with a binding get live data on a refresh cadence. The loop:
  1. Connect a data source (Stripe, Postgres, webhook, or outbound MCP — see Connecting data).
  2. Bind the block to it — via the blocks.bindData MCP tool — with a queryConfig matching the source type (e.g. webhook.latest) and a projection that maps the raw result into manifest.widget.data.
  3. Read the data in your block. Published code-block renders inject the current data and a small runtime before your entry script runs.
  4. Refreshes happen out-of-band — the connector cron (or a scheduled run) re-runs the query and rewrites widget.data; your block picks up changes by polling.
The injected runtime contract:
Fetch errors during polling are silent — your callback just keeps the last good value. A minimal index.html consumer:
Bound data on a publicly-visible canvas is public — anyone who can load the block can read its widget.data via the render and /api/blocks/<id>/data endpoints. Don’t bind secrets or per-user private data to a public block.
Data sources and bindings require the Builder plan. For agents pushing one-shot data without a connector — for example, a scheduled-run agent that computes a value and stuffs it into the widget — there’s the dedicated blocks.setWidgetData MCP tool. It writes directly to widget.data without changing source files. See MCP tool reference.

Forked templates: rebind to your own data

A forked data-bound template carries its own setup instructions. blocks.get on the fork returns manifest.binding — a binding template with the queryConfig (e.g. webhook.latest plus its params) and the projection the original block used. Its dataSourceId is the placeholder sentinel "template": there is no live data source or binding row behind it, so the fork renders in its no-data state until you rebind it. To make the fork live, reuse the recipe:
  1. blocks.get → read manifest.binding (queryConfig + projection).
  2. dataSources.add → create a source whose connector type matches the queryConfig type prefix (webhook.* needs a webhook source, stripe.* a Stripe source, and so on).
  3. blocks.bindData → pass the new dataSourceId plus the template’s queryConfig and projection verbatim.
The bind triggers an immediate refresh that writes manifest.widget.data, and the block’s chatblocksData runtime picks it up on its next poll. blocks.listBindings confirms the live row exists. In the web editor, the same recipe powers the Data binding panel: a forked template shows a “Template binding” note, and picking a source of the matching type prefills the query and projection from the manifest.

Versioning behavior

A new published version supersedes the old one only for placements pinned to “latest.” Placements pinned to a specific version are stable until you change them. The render route uses the publishedVersionId carried on the placement (or the latest version if rendering outside a placement context — e.g. the editor preview, or a direct block-detail link). Re-publishing the same source produces a new blockPublishedVersions row with a new bundleStorageId even if the build output is byte-identical — versions are append-only, not deduplicated.

What the iframe can’t do

The sandbox is the trust boundary. A few things the iframe explicitly cannot do, regardless of what the source code tries:
  • Read cookies from the parent origin. same-origin is denied; the iframe has its own opaque origin.
  • Reach the platform’s internal APIs. Convex endpoints are not in any block’s CSP allowlist.
  • Navigate the top frame. allow-top-navigation is not set.
  • Pop up windows. allow-popups is not set.
  • Persist anything across reloads unless the storage.kv permission is declared, in which case the platform exposes a key/value API scoped to the viewer.
If something feels artificially constrained, check the manifest’s permissions array — most “missing capability” issues are unset opt-ins, not platform bugs.

What’s next

Manifest

Every field that controls what the runtime allows.

Widget

The iOS widget projection that rides alongside the runtime render.