> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chatblocks.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Runtime

> How blocks render in the iframe — render paths, sandbox model, data injection, the isRenderable predicate.

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`:

<Tabs>
  <Tab title="format: code">
    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.
  </Tab>

  <Tab title="format: embed">
    No source code, no build. `manifest.embed.url` points at a real website. The render route emits a tiny HTML shell that hosts a nested sandboxed iframe pointing at the embed URL with the declared sandbox profile (`scripts-only` default, or `strict` for no JS).

    Embed URLs are probed for framability at publish time (e.g. `X-Frame-Options: DENY` rejected upfront). The URL is visible in the block chrome for moderation.
  </Tab>
</Tabs>

## 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`:

```ts theme={null}
const isRenderable =
  hasBundle ||
  (format === "embed" && hasEmbedUrl);
```

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](/connecting-data/stripe)).
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:

```js theme={null}
// Set synchronously before your entry script:
window.__CHATBLOCKS_DATA__ = { data: /* projected data or null */, updatedAt: /* ISO or null */ };

window.chatblocksData.get();           // returns the current { data, updatedAt }
const unsubscribe = window.chatblocksData.subscribe((payload) => {
  // called immediately with the current value, then with each fresh
  // payload polled from /api/blocks/<id>/data
}, 30000);                             // intervalMs: default 30s, clamped to >= 5s
unsubscribe();                         // stops the polling
```

Fetch errors during polling are silent — your callback just keeps the last good value. A minimal `index.html` consumer:

```html theme={null}
<div id="value">loading…</div>
<script>
  window.chatblocksData.subscribe(function (p) {
    document.getElementById("value").textContent =
      p.data ? JSON.stringify(p.data) : "no data yet";
  });
</script>
```

<Warning>
  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.
</Warning>

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](/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

<CardGroup cols={2}>
  <Card title="Manifest" icon="file-code" href="/authoring/manifest">
    Every field that controls what the runtime allows.
  </Card>

  <Card title="Widget" icon="window" href="/authoring/widget">
    The iOS widget projection that rides alongside the runtime render.
  </Card>
</CardGroup>
