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

# Embed blocks

> Put your own UI on a Custral page, with scoped read access to the block it sits on.

## What an embed block is

An embed block frames one of your pages inside Custral. Your page renders in
its own origin, and (if you ask for it and the workspace agrees) receives a
short-lived credential it can use to read the data the block is configured to
show.

Two things are worth being clear about before you build one.

<Note>
  **A customer can already frame your page without you.** The built-in
  [Embed block](/blocks/embedded/embed) takes any `https://` URL. What
  declaring one buys you is *context*: which record the reader is on, which
  filters are applied, and a token to read rows with.
</Note>

<Warning>
  **A declared embed is reviewed by Custral before any workspace can install
  it.** The origin and the scopes are frozen once it is listed. Changing
  either sends it back for re-review, and the block drops out of the catalog
  until it is approved again. Changing the *path* does not, so ordinary
  releases are unaffected.
</Warning>

## Declaring one

An embed block is declared on your provider manifest alongside your properties,
sync and AI pack:

```ts theme={null}
embeds: [
  {
    key: "acme.overview-panel",
    name: "Acme overview",
    description: "Your Acme dashboard, on the record.",
    icon: "chart-line",
    module: {
      key: "acme.panels",
      name: "Acme panels",
      icon: "chart-line",
      description: "Acme's embedded panels.",
    },
    embed: {
      urlTemplate: "https://embed.acme.com/panel?record={{record.id}}&email={{record.email}}",
      origin: "https://embed.acme.com",
      scopes: ["records:read"],
    },
  },
],
```

| Field         | Notes                                                                                                                                                                                              |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key`         | Must start with your provider id, e.g. `acme.`                                                                                                                                                     |
| `module`      | **Required.** A block with no module appears in every workspace the moment it is seeded. Behind a module it appears only once an admin installs it, and installing is what shows them your scopes. |
| `origin`      | Must be `https://`. Frozen once listed.                                                                                                                                                            |
| `urlTemplate` | Must resolve *within* `origin`. Not frozen.                                                                                                                                                        |
| `scopes`      | Frozen once listed. Ask for the least you need. The list is shown to the admin verbatim.                                                                                                           |

## The template placeholders

| Placeholder                | Resolves to                              |
| -------------------------- | ---------------------------------------- |
| `{{org.id}}`               | The workspace                            |
| `{{user.id}}`              | The viewer                               |
| `{{component.id}}`         | This block instance                      |
| `{{record.id}}`            | The open record, on a record page        |
| `{{record.<propertyKey>}}` | That property's value on the open record |

Every value is URL-encoded, so a value containing `&`, `#` or a space arrives as
one parameter rather than changing the shape of your URL.

<Warning>
  **An unresolved placeholder becomes an EMPTY parameter, never the literal
  `{{…}}`.** On a dashboard there is no open record, so `{{record.id}}` is
  empty; on a record with no value for the property, `{{record.email}}` is
  empty. Read a missing parameter as "not available here", and never as a
  value. A literal `{{record.email}}` would reach your logs looking like a
  real address.
</Warning>

A template that resolves to a different origin than you declared is **refused**,
not corrected. The block renders a refusal instead of framing anything. That
includes a placeholder in the host: `https://{{org.id}}.acme.com/…` is rejected
at declaration time.

## A worked example: a panel on a record

Point the block at a record page and read the record it is on.

```ts theme={null}
embed: {
  urlTemplate: "https://embed.acme.com/panel?record={{record.id}}&email={{record.email}}",
  origin: "https://embed.acme.com",
  scopes: ["records:read"],
}
```

On a Contacts record whose email is `dana@acme.com`, your page is framed at:

```
https://embed.acme.com/panel?record=rec_123&email=dana%40acme.com
```

On a contact with no email, and on a dashboard with no open record:

```
https://embed.acme.com/panel?record=rec_123&email=
https://embed.acme.com/panel?record=&email=
```

The `record` and `email` parameters are always present. Their emptiness is the
signal.

## The handshake

Your page and Custral talk over `postMessage`.

<Steps>
  <Step title="Your page posts `custral:ready`">
    Attach your `message` listener **first**. The host replies immediately, and
    a listener attached after the post misses `custral:init`.
  </Step>

  <Step title="Custral replies with `custral:init`">
    Carrying `protocolVersion`, your `token`, and the first `context`.
  </Step>

  <Step title="Custral sends `custral:context` whenever anything changes">
    A filter moved, the reader opened a different record, the theme changed.
  </Step>

  <Step title="Your page posts `custral:resize` as its content grows">
    The host clamps the height. Report it from a `ResizeObserver`, not once on
    mount.
  </Step>
</Steps>

<Warning>
  **Re-fetch on `context.revision`, not once on `custral:init`.** `revision`
  changes whenever anything you should re-read has changed. A frame that
  fetches once shows stale data the moment a dashboard filter moves, and the
  reader has no way to tell.
</Warning>

Validate every message on **both** axes: the origin it came from *and* its
shape. The origin alone lets any message through from the right host; the shape
alone lets any host send a well-formed one.

## The token

`custral:init` carries a short-lived token scoped to **one block instance**.

* Send it as `Authorization: Bearer <token>` to `/v1`.
* It expires in 15 minutes and is reusable within that window.
* It never appears in your URL, and you must never put it there. URLs leak
  into access logs, `Referer` headers, browser history and forwarded links.
* It carries your declared scopes **intersected with what the viewer can
  already do**. A viewer who cannot write never hands you a token that can.
* Uninstalling your provider stops it working at once, not when it expires.

Read the rows behind your own block with:

```
POST /v1/components/<componentId>/rows
```

The filters are applied server-side from the block's own configuration. You
cannot send your own. That would read past what the block was set up to show.

## What you cannot do

|                                             |                                                      |
| ------------------------------------------- | ---------------------------------------------------- |
| Navigate the reader's tab away from Custral | Blocked by the sandbox                               |
| Write over `postMessage`                    | Writes go through `/v1`, behind a scope              |
| Read another block's rows                   | The token names one component                        |
| Be framed by Custral at a Custral origin    | Refused: a same-origin frame is not sandboxed at all |

## A reference implementation

`apps/web/src/app/embed-demo` in the Custral repository is a working embed: it
posts `custral:ready`, renders the context, reports its height from a
`ResizeObserver`, and makes one `/v1/me` call with the token. It is written to
be copied.


## Related topics

- [Embed](/blocks/embedded/embed.md)
- [Embedded Components](/blocks/embedded/overview.md)
- [Phone & SMS Settings](/blocks/embedded/phone/settings.md)
- [Form](/blocks/embedded/object/form.md)
- [Inbox](/blocks/embedded/object/inbox.md)
