/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 onmanifest.format:
- format: code
- format: embed
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 incanvas-host.tsx → toNodes:
Bundle storage
Forcode blocks, the blockPublishedVersions row carries:
bundleStorageId— Convex storage id pointing at the compiled HTML bundle.publishedAt— when this version was cut.manifest— frozen snapshot ofchatblocks.jsonat publish time.
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:codeblocks: scripts allowed, same-origin denied, top navigation denied, popups denied, forms allowed only when theformspermission is declared in the manifest.embedblocks: same baseline, with the additional inner-iframe profile (scripts-onlyorstrict) applied to the embedded site.
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 abinding get live data on a refresh cadence. The loop:
- Connect a data source (Stripe, Postgres, webhook, or outbound MCP — see Connecting data).
- Bind the block to it — via the
blocks.bindDataMCP tool — with aqueryConfigmatching the source type (e.g.webhook.latest) and aprojectionthat maps the raw result intomanifest.widget.data. - Read the data in your block. Published code-block renders inject the current data and a small runtime before your entry script runs.
- 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.
index.html consumer:
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:
blocks.get→ readmanifest.binding(queryConfig+projection).dataSources.add→ create a source whose connector type matches thequeryConfigtype prefix (webhook.*needs a webhook source,stripe.*a Stripe source, and so on).blocks.bindData→ pass the newdataSourceIdplus the template’squeryConfigandprojectionverbatim.
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 thepublishedVersionId 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-originis 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-navigationis not set. - Pop up windows.
allow-popupsis not set. - Persist anything across reloads unless the
storage.kvpermission is declared, in which case the platform exposes a key/value API scoped to the viewer.
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.