Skip to main content

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.
A customer can already frame your page without you. The built-in Embed block 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.
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.

Declaring one

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

The template placeholders

Every value is URL-encoded, so a value containing &, # or a space arrives as one parameter rather than changing the shape of your URL.
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.
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.
On a Contacts record whose email is dana@acme.com, your page is framed at:
On a contact with no email, and on a dashboard with no open record:
The record and email parameters are always present. Their emptiness is the signal.

The handshake

Your page and Custral talk over postMessage.
1

Your page posts custral:ready

Attach your message listener first. The host replies immediately, and a listener attached after the post misses custral:init.
2

Custral replies with custral:init

Carrying protocolVersion, your token, and the first context.
3

Custral sends custral:context whenever anything changes

A filter moved, the reader opened a different record, the theme changed.
4

Your page posts custral:resize as its content grows

The host clamps the height. Report it from a ResizeObserver, not once on mount.
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.
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:
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

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.