Skip to content

Embedding a case

This guide is for a developer putting one Underwriting Guru case inside a page their own platform serves. It is the canonical description of that integration: the SDK package’s own README is its reference card, and examples/embed-host/ is a runnable host you can start in a few minutes — but the walkthrough, the failure modes and the reasons live here.

Every example uses https://app.example.com as a stand-in for the host serving your Underwriting Guru deployment, https://your-platform.example for your own page, and acme for a workspace slug.


One case, read-only, in an iframe on a page the workspace has explicitly allow-listed.

That sentence is the whole scope, and everything below follows from it:

  • One case. The frame is bound to a single case for its whole life. There is no case list, no search, no workspace switcher, no admin surface. You cannot steer it to a second case — there is no message shape that expresses one.
  • Read-only. Not “the buttons are greyed out” and not “the server would refuse”. A mutation affordance does not exist in the frame: the run-again panel, the override dialog, the accept/reject controls, the reparse and classification controls, the add-documents dialog and the case export are all absent, and the frame’s own fetch layer throws on any non-GET before it reaches the network. Your user cannot change the record, and there are no mutation-lifecycle messages for you to listen for, now or ever.
  • On an allow-listed page. Embedding is off for every workspace until an administrator lists an exact https:// origin. An unlisted page gets nothing, and cannot tell that from “no such workspace” — see §9.

What the reader sees inside the frame is the case’s three tabs — Documents, Underwriting (the evaluation and its decision) and Activity (that case’s audit timeline) — with the document viewer available from any citation. The header also carries a small, permanent, un-themeable “Underwriting Guru · read-only” attribution line — it is not a token you can turn off or recolour (see §4.5) — because an underwriting verdict shown inside another product’s chrome needs to say whose system decided it.

There is no sign-in inside the frame, ever. No OIDC redirect, no cookie, no login form: a third-party frame has partitioned storage and no third-party cookies, so all three are dead on arrival. Identity arrives once, from your backend, as a coupon (§3).


Three things, and the first is the one that stops most first attempts.

2.1 An administrator allow-lists your page’s origin

Section titled “2.1 An administrator allow-lists your page’s origin”

Embedding is per-workspace and fail-closed: the allow-list is empty for every workspace until someone adds an origin, and an empty list means embedding is off. There is no deployment-wide switch to flip.

Two surfaces do this, and both go through the same PUT /v1/tenant-settings contract and the same server-side validation:

  • In the app: Admin → Workspace settings → Embedding. The workspace’s own administrator lists the allowed sites, and the card shows the exact frame address to hand you.

  • ug-admin embed allow-origin — the operator path, for a customer whose administrator has not signed in yet:

    Terminal window
    ug-admin embed allow-origin list --tenant acme
    ug-admin embed allow-origin add --tenant acme --origin https://your-platform.example
    ug-admin embed allow-origin remove --tenant acme --origin https://your-platform.example

    --tenant takes a slug or an id. list on a workspace with no origins prints that embedding is off rather than printing nothing.

The rules, all enforced at write time (a 422 with a JSON-Pointer field error naming the offending index and the rule it broke, and nothing stored):

rule
Exact https:// origins only Scheme + host + optional port. No path, query, fragment or userinfo.
No wildcards https://*.your-platform.example is refused. List each site.
At most 50 origins, 255 characters each
Origins are lower-cased and de-duplicated So the check at handshake time is an exact equality on a canonical value.

PUT /v1/tenant-settings is a FULL REPLACE. If you or the workspace’s own tooling calls it directly, GET the document first, change the one field, and PUT the whole thing back — sending only embed_allowed_origins silently clears the workspace’s time_zone (every rendered timestamp falls back to UTC) and its notification-retention setting. Both surfaces above already do this for you.

Every save is audited as tenant.settings.updated on the workspace’s own append-only chain, carrying the origin list in both before and after — withdrawing a site is as recoverable a fact as granting one.

2.2 Why http can never work, including http://localhost

Section titled “2.2 Why http can never work, including http://localhost”

The allow-list refuses a non-https origin at write time, so http://localhost:3000 cannot be stored. That is not an arbitrary policy: a browser will never let a plain-http page frame an https page (framing counts as blockable mixed content), so an http origin could not embed the app even if we stored it. Storing one would buy exactly nothing and would hide the real failure.

For local work you need a real certificate for localhost — see §10.

2.3 Your backend holds an embed:mint-scoped API key

Section titled “2.3 Your backend holds an embed:mint-scoped API key”

Minting a handoff is a server-to-server call authenticated with a workspace API key carrying the embed:mint scope. That key must never reach page JavaScript: a key in the browser is a key that mints coupons for any subject and any case in the workspace.

embed:mint grants nothing else — no data access of its own, only the ability to start the embed handshake for a subject the exchange will still independently verify. Ask for a key with that scope and no other.

The workspace’s administrator creates it in the app (POST /v1/api-keys), or an operator can mint one directly:

Terminal window
ug-admin mint-api-key --tenant-slug acme --name "your-platform embed" \
--scopes embed:mint --expires-in 24h

The plaintext key is printed once and cannot be recovered — only its hash is stored.

https://app.example.com/embed/acme

The path carries only the workspace slug — the coupon is deliberately not in the URL, so it never lands in browser history, an ingress access log, or a Referer on the next hop.

It must be the same host that serves /v1. This is not a style preference: the frame’s API calls are same-origin by construction and there is no CORS anywhere in this system, so a frame on any other host cannot talk to the API at all. Every real deployment already has one such origin — its own ingress routes / to the app and /v1 and /embed to the API.


your page your backend app.example.com
| | |
|-- "open case X" ------------>| |
| |-- POST /v1/embed/handoffs ----->| (API key, embed:mint)
| |<-- {handoff, expires_at} -------| ~60 s, single-use
|<-- the coupon ---------------| |
| |
|-- <iframe src=".../embed/acme"> ----------------------------->|
|<-- the embed document (CSP frame-ancestors: your origin) ------|
| |
|-- postMessage ug:init{handoff}, targetOrigin=app origin ------->|
| (the frame exchanges it)
| POST /v1/embed/sessions {handoff, host_origin}
|<-- ug:ready{scenarioId, expiresAt, view} ----------------------|
POST /v1/embed/handoffs
Authorization: Bearer <your embed:mint API key>
Content-Type: application/json
{
"subject_email": "[email protected]",
"scenario_id": "0198c5f2-4a10-7d22-8b19-9f1e2c3a4b5c",
"host_origin": "https://app.acme.example"
}
{ "handoff": "<opaque token>", "expires_at": "2026-09-02T18:03:07Z" }
  • subject_email is the Underwriting Guru user this frame acts as. It is matched, case-folded, to an active user in the workspace — at the exchange, never at mint time. Embedding is not an admission door: an email with no active user is a legal mint and a refused exchange, and it never provisions anybody.
  • scenario_id is the one case this coupon opens. Also verified at the exchange, not here.
  • host_origin is the exact https:// origin your page frames us from, and it is required. Your backend knows it; do not read it off the browser’s request. The coupon is stored bound to it, and the exchange refuses unless the origin the frame measures from your postMessage equals it — so a coupon that leaks is usable only from your own site, instead of from anywhere by naming any allow-listed origin. It must also be allow-listed (§2.1): the same string in both places. A malformed value (http, a wildcard, a path) is a 422 here.
  • The handoff value is shown only in this response and stored hashed at rest. A lost coupon is re-minted, never recovered.
  • It lives about 60 seconds and is single-use. Mint it in response to the click that opens the frame, not ahead of time.

Because verification is deferred to the exchange, a mint tells you nothing about whether the case or the subject exists — that is deliberate, so the mint endpoint is not an existence oracle for a workspace’s users or cases.

Your mint endpoint is a privileged endpoint

Section titled “Your mint endpoint is a privileged endpoint”

This is the obligation with the largest blast radius in the whole integration, so it gets its own heading.

The route on your backend that calls POST /v1/embed/handoffs holds a credential that can mint a coupon for any active user in your workspace and any case in it. We cannot check the part that matters: the exchange re-verifies that the subject is an active user and that the case belongs to the workspace, and it has no way to know whether the caller was entitled to ask on that subject’s behalf. A mint route with nothing in front of it is the API key in the browser, reachable by anyone on the internet.

Four things, and none of them is optional:

  1. Authenticate the requester — your session, JWT or SSO middleware, the same gate your own pages sit behind. Never an open route.
  2. Take subject_email from that session, not from the request body. A body value is a value the caller chose. If your UI genuinely needs to act for someone else (a supervisor opening a colleague’s case), authorize that act explicitly; do not let the body assert it.
  3. Authorize the case — verify the subject may read the scenario_id they named, in your own model. Read-only and single-case containment limits the damage of a wrong answer here; it does not make the check unnecessary.
  4. Defend against CSRF — this is a credentialed, state-changing POST, so it needs whatever anti-CSRF your framework provides. SameSite cookies are a mitigation, not a guarantee.

examples/embed-host implements all four (a demo session cookie, the subject read from the session, a mayReadCase() stub to replace, a double-submit token), so the pattern you copy contains the step rather than describing it.

3.2 Your page posts the coupon into the frame

Section titled “3.2 Your page posts the coupon into the frame”
frame.addEventListener("load", () => {
frame.contentWindow.postMessage({ ug: 1, type: "ug:init", handoff }, "https://app.example.com");
});

Two rules, and both are security properties:

  1. targetOrigin must be the app origin, never "*". postMessage(msg, "*") works perfectly and broadcasts your single-use coupon to every frame on the page. Nothing turns red.
  2. Post it exactly once. The coupon is single-use and the frame pins its host origin on the first well-formed ug:init; a second one could only ever carry a spent coupon.

load is early enough, and that is a guarantee. The frame installs a message listener in the same task that runs its entry script — before load can fire — and buffers your ug:init until its own dispatcher is up, replaying the real MessageEvent so the origin pin is still set from the browser’s unforgeable value. You do not need to delay the post, retry it, poll for a signal, or wait for anything from the frame first.

(This is worth stating plainly because it was not always true. An earlier build armed the frame’s dispatcher several round trips after load, and a host doing the documented, correct thing had its coupon dropped by the platform with nothing in either console. If you are reading an older note that says a message posted before the frame is listening is lost permanently, that note is stale.)

Measured end to end in the production configuration — a real cross-origin host over https, the built assets, ug-api serving the embed document — iframe loadug:ready is under 100 ms (95 / 67 / 73 ms across three runs).

3.3 The frame exchanges it, and tells you when it is steerable

Section titled “3.3 The frame exchanges it, and tells you when it is steerable”

The frame calls POST /v1/embed/sessions itself with the coupon and the host_origin it measured from the MessageEvent — the one value in the exchange nobody on the page can forge. The server checks that the coupon is live, unexpired and unspent; that the subject is an active user in the workspace; that host_origin equals the origin the coupon was minted for and that that origin is in the allow-list; and that the case belongs to that workspace. (What keeps a page you did not authorize out in the first place is your allow-list reaching the browser as frame-ancestors, plus this API setting no CORS headers at all; the origin checks here narrow what a coupon is worth, they are not the door.) Every failure returns the same 401, so the response is not an oracle for which origins, users or cases exist.

On success the frame holds an opaque session bearer in memory only — never localStorage, never sessionStorage — good for about 30 minutes, resolving to a read-only principal that may GET only its bound case and that case’s sub-resources.

Then it posts ug:ready. ug:ready gates steering, never the coupon: wait for it before you navigate or theme, because a ug:navigate that arrives earlier is ignored with no reply at all. The SDK holds your initial theme and view for you, so you generally never think about it.

ug:ready deliberately waits for the case and its document list to settle, because “ready” has to mean “ready to be steered” — a ug:navigate naming a document is checked against that list, and announcing readiness first would turn a load still in flight into a refusal. “Settled” includes failed: you are never left waiting forever on a case that could not be read.

The coupon is spent and the session lives in memory. Any reload — including your reader pressing refresh — means minting a fresh handoff. Your backend endpoint should expect to be called on every frame load.


You can hand-roll the host half; the contract is small. But three ways of getting it wrong fail silently — posting to "*", reading event.data without checking event.origin, and checking the origin but not event.source (a hostile page can open a second frame on the app origin) — so we publish @ug/embed-sdk, which makes the correct thing the only thing. "*" appears nowhere in it, and a test asserts that as a spy on postMessage.

It is not on npm. npm i @ug/embed-sdk will not work: the package is private: true and built inside this workspace, deliberately — an integrator copies the pattern into their own stack rather than inheriting our build. Two supported routes:

  • Vendor the three files. src/index.ts, src/protocol.ts and src/theme-tokens.ts are the whole package. They import nothing but each other, so dropping them into your own src/ and fixing the two relative imports is the entire integration. This is the recommended route, and the reason the package carries an enforced size budget and zero runtime dependencies.
  • Use the built dist/. pnpm -F @ug/embed-sdk build emits ESM plus .d.ts. If you serve it to a browser’s native import rather than through a bundler, you must append .js to every bare relative specifier the package emits (from "./protocol"from "./protocol.js", and likewise each export *) — a bundler resolves them, a browser cannot. examples/embed-host/’s npm run vendor-sdk does exactly this and nothing else; every security-relevant line is untouched.
import { mountUnderwritingEmbed } from "@ug/embed-sdk";
// `handoff` came from YOUR backend (§3.1). The API key never reaches this code.
let embed = mountUnderwritingEmbed({
container: document.getElementById("underwriting"),
appOrigin: "https://app.example.com",
tenantSlug: "acme",
handoff,
theme: { mode: "light", tokens: { accent: "#0a766d" } },
view: { tab: "evaluation" },
onEvent: (event) => {
if (event.type === "ug:session-expiring") void renew();
},
onTimeout: ({ causes }) => showYourOwnFallback(causes),
});
option default
container The element the iframe is appended to. It must already be in the document: an iframe inside a detached element never loads, so the handshake could never start — the SDK throws for this at your call site.
appOrigin Scheme + host + optional port, no path, query or fragment. Throws otherwise, rather than quietly building a nonsense src. It is the only origin the SDK ever posts to and the only one it accepts a message from.
tenantSlug The workspace slug, percent-encoded into /embed/{slug}.
handoff The single-use, ~60 s coupon your backend minted.
theme { mode?, tokens? }, validated at the call site and applied on ug:ready.
view Initial { tab, … }, validated at the call site and applied on ug:ready. Omit it to get the frame’s own first tab.
autoResize true Follow ug:resize by setting the iframe’s height. false still delivers the events to onEvent.
height 720 Starting height in CSS pixels — what a reader sees for the first round trip, before the frame can measure anything.
title "Underwriting case" The iframe’s accessible name. An iframe with no title is an unlabelled frame to a screen-reader user navigating your page.
sandbox "allow-scripts allow-same-origin" false omits the attribute entirely. See below.
placeholderBackground theme.tokens["surface-canvas"], else unset A colour for the iframe element while the frame is still a neutral waiting shell.
readyTimeoutMs 15000 How long to wait for ug:ready before reporting the handshake never happened. 0 or negative disarms it.
onTimeout ({ causes }) => … if that timer lapses. A console.warn naming the same causes is emitted either way.
onEvent Every verified frame→host message, in arrival order.

Two further options, window and createIframe, are test seams — ignore them; a TypeScript integrator will still see them on the type, but they exist for the SDK’s own test suite, not for you.

The mount also sets an explicit empty allow (Permissions Policy) on the iframe: the embed asks for no camera, no microphone, no geolocation and no clipboard, and stating it stops your page-level policy being inherited into the frame.

On sandbox: allow-scripts allow-same-origin is the minimum, and allow-same-origin is a requirement, not a loosening — the session bearer and the query cache live in the frame’s own origin, and dropping it puts the frame in an opaque origin that cannot call the very API it exists to read. What is absent is the interesting part: no allow-forms (the embed submits none), no allow-popups (it opens no windows), no allow-top-navigation (it never navigates your page), no allow-downloads (the frame composes no save-a-file control at all).

placeholderBackground, and why it is a host-side option. The frame ignores ug:theme before the handshake completes, on purpose: an arbitrary page that framed us must not be able to repaint the neutral waiting shell in its own colours, because that is the phishing prop the neutral state exists to avoid. The measured consequence is that a reader whose OS prefers dark sees a near-black rectangle inside a light host page for that one round trip. This option paints your own iframe element with your own CSS; the frame’s DOM is untouched and nothing about it reaches us.

embed.ready is false until the frame reports ug:ready. If it never does, the SDK — after readyTimeoutMs, 15 s by default — emits a console.warn and calls onTimeout({causes}) with exactly three causes, in the order to check them:

  1. this page’s origin is not in the workspace’s allowed sites
  2. embedding is off for this workspace
  3. appOrigin/tenantSlug do not resolve to an embed document

This is the only diagnosis available for the likeliest first-run failure, and the causes are a closed list because the SDK genuinely cannot tell which one it is. When embedding is not enabled for your origin the app answers the iframe’s request with a plain 404 and frame-ancestors 'none'. Our script is never in that document, so the frame’s own diagnostics cannot run — your ug:init is posted into an error page and dropped, you get zero events, and without this timer you get a blank rectangle with nothing in either console. Naming all three is the honest answer; a refusal that named the real reason would be an oracle for which workspaces exist on the deployment.

15 s is a whole coupon lifetime plus slack, and deliberately longer than the frame’s own 10 s no-handshake state: when the frame is alive it should be the one to speak. This timer is for the case where our script never ran at all.

embed.navigate({ tab: "documents", documentId, blockId, documentPane: "pages" });
{ tab: "documents" | "evaluation" | "activity",
documentId?: string | null, // open the document viewer on it; null closes it
blockId?: string, // highlight this layout block
factPath?: string, // highlight this fact path
documentPane?: "pages" | "text",
scenarioId?: string } // optional assertion: must equal the bound case

That is the entire navigable space. There is no shape of this object that addresses a second case, a list, or an admin route.

scenarioId is worth using if your page renders more than one embed — a queue with two cases side by side can post to the wrong frame, and a silently-applied navigate in the wrong frame is a confusing bug. It grants nothing: the frame is bound to one case by its session, so this field can only ever cause a refusal, never a redirection.

Two failures, and telling them apart is the point of having two codes:

  • A view this contract cannot express — a tab outside the three, a field of the wrong type — navigate() throws at your call site, before anything is posted. A hand-rolled host that posts it anyway gets ug:error{code:"invalid_message"}: you spelled it wrong.
  • A scenarioId that is not the bound case, or a documentId that is not one of that case’s documentsug:error{code:"not_in_scope"}: that is not yours to see. A document belonging to another case and a document that does not exist are indistinguishable to you, deliberately — that is what keeps ug:navigate from becoming a probe for what exists elsewhere. A refusal is atomic: an out-of-scope document leaves the tab alone too.

Called before ug:ready, the SDK holds the request and posts it on ready; only the most recent held view survives. The frame also posts ug:navigate to you when the reader changes tab or opens the viewer, so your page can mirror where they are.

embed.setTheme({ mode: "dark", tokens: { surface: "#101826", text: "#e8eef7", accent: "#c9a227" } });

mode is "light" or "dark". tokens is a closed set of eleven keys:

surface surface-subtle surface-canvas text text-muted border
accent accent-hover accent-foreground accent-subtle accent-subtle-foreground

Three grounds, the ink on them, the separator, and the everyday accent with its own pairings. Enough to make the frame look like it belongs on your page, and not enough to make it say something else.

Status colours are not themeable, ever: a status colour is information, not chrome, and a host that could paint “abstained” green would be misrepresenting an underwriting record. Fonts are not themeable either — the embed document’s CSP is font-src 'self', so a host-supplied face could not load, and a token that silently does nothing is worse than no token. Nor is the “Underwriting Guru · read-only” attribution line (§1) — it has no token of its own, so no tokens call can hide or recolour it.

A plain colour literal, in one of CSS’s numeric spellings, at most 32 characters:

  • #rgb, #rgba, #rrggbb, #rrggbbaa
  • rgb() / rgba() with three number-or-percentage channels and an optional alpha, in either the legacy comma form or the modern space-plus-slash form
  • hsl() / hsla() with an angle and two percentages, same two forms

Refused: named colours (red), currentColor, inherit, transparent, gradients, var(), calc(), color-mix(), url(), and anything containing ; { } < > @ /* or a backslash. currentColor and inherit are indirections; transparent on a ground token makes your page’s background the frame’s text background, with no contrast guarantee at all.

A tokens field replaces the whole override set. Any key you omit — and any key the frame rejects — falls back to the design system’s own value. There is no merge with a previous call and no partial reset, so post the whole palette you want, every time. tokens: {} is a full reset; a setTheme({mode}) carrying no tokens field leaves the palette alone.

A call made before ug:ready is held with those exact semantics, so your palette does not depend on whether ready had landed yet: mode is sticky across a call that omits it, tokens is replaced only when a call carries the field, and a mode-only hold posts no tokens key at all.

They are written to the frame’s body and win in both modes. So {tokens: {surface: "#ffffff"}} followed by {mode: "dark"} leaves the dark-mode ink on a white ground — a contrast failure produced by two individually legal calls. Post a complete, mode-appropriate token set with every mode change, or post no tokens at all and let the frame’s own palette follow the mode.

The contrast guard, and insufficient_contrast

Section titled “The contrast guard, and insufficient_contrast”

A legal colour can still make the record unreadable, so the accepted palette is measured as pairs against the values that will be in force — yours where you sent one, ours for every key you did not:

ink ground floor
text surface, surface-subtle, surface-canvas 4.5:1
text-muted surface 4.5:1
accent-foreground accent 4.5:1
accent-subtle-foreground accent-subtle 4.5:1
border surface 3:1, relaxed to our own measured ratio

accent-hover is unpaired — it is a hover ground for the same accent-foreground, and the design system holds that relationship itself, so darkening it cannot make a sentence unreadable.

Four properties worth knowing:

  • A failing pair costs only the keys you sent in that pair. The rest of your palette still applies, and ug:error{code:"insufficient_contrast", key} names each refused key precisely.
  • Only your keys are refusable. If a pair fails because your ground meets our default ink, your ground is refused and our ink is untouched.
  • The guard is never stricter than the design system’s own pairing. Where our two defaults sit below a pair’s nominal floor, that measured default ratio becomes the floor. border/surface is why: our shipped separator is a hairline at 1.56:1 light and 1.49:1 dark, so a flat 3:1 would refuse a host border exactly as legible as our own. A consequence: the default palette passes by construction, in both modes.
  • Refusals cascade. A refused key reverts to our value, which makes every pair it belongs to a different measurement, so the palette is re-measured until nothing more is refused. An incoherent partial set — a dark surface with no text-muted of its own, say — can therefore lose more keys than the one that failed first. The errors name every one. Sending a complete, coherent set is what avoids it.

Translucent values (#rrggbbaa, rgba() with alpha) are refused for a paired token: a see-through ground has no single ratio, because what is behind it is your page.

setTheme throws for the pairs it can see — both keys present in the same call — so a typo surfaces at your call site with your own stack on it. The frame is the authority for the rest: it is the only party that knows the value behind a key you left alone, so a clean setTheme is not a promise that every key landed. Watch ug:error if you care.

embed.destroy();

Removes the message listener and the iframe. Idempotent; every other method becomes a no-op afterwards. This is also half of renewal (§6).


Every message is { ug: 1, type: "ug:…", … }. The ug discriminator is what lets the frame share a window with your analytics beacons, chat widgets, sibling frames and bundler hot-reload chatter and still recognise its own traffic without guessing.

Versioning. ug is currently 1. An unknown type inside a version we speak is ignored silently — that is how a newer host talks to an older frame within one envelope version, and the additive half has to be silent to be useful. An unknown ug value is refused audibly with unsupported_version — at most three times before the frame is pinned to an origin (MAX_UNPINNED_REFUSALS in the frame’s own message handler), so a page that floods us with mismatched envelopes cannot make us flood back; a fourth mismatched envelope from the same unpinned origin gets silence, not a fourth error.

message payload when refusals
ug:init {handoff} once, on the iframe’s load (posted earlier is buffered) a second one is ignored; a missing handoff → invalid_message
ug:theme {mode?, tokens?} any time after ug:init — ignored entirely before it malformed mode/tokensinvalid_message; a bad token key or value → invalid_token per key; a legal token that would drop the record below WCAG AA → insufficient_contrast per key
ug:navigate {view} any time after ug:ready unexpressible view → invalid_message; outside the one case → not_in_scope; before ug:ready: ignored, with no reply at all

ug:theme and ug:navigate are ignored before the exchange because the neutral waiting shell must carry no ambient authority — an arbitrary page that framed us must not be able to repaint it.

message payload when
ug:ready {scenarioId, expiresAt, view} once, when the case and its document list have settled
ug:session-expiring {expiresAt} once, 60 s before the lapse (immediately, if already inside that window)
ug:session-ended (none) once, on refusal, expiry, or a 401 clearing the session
ug:navigate {view} the reader changed tab, or opened/closed the document viewer
ug:resize {height, bounded} the content height changed (throttled 100 ms, bounded at 2400 px)
ug:error {code, key?} a refusal, per the table above

ug:resize may arrive before ug:ready. Harmless in both directions — you size the frame a little early, and the SDK applies a height whether or not it has seen ready.

The five ug:error codes, and what to do about each

Section titled “The five ug:error codes, and what to do about each”
code means your action
unsupported_version Your envelope’s ug value is not one this frame speaks. Before the frame is pinned to an origin, at most three of these are sent (see the versioning note above); a debugging loop that keeps posting past that will see the errors simply stop, not resolve. Update your copy of the SDK / protocol module. Nothing you send will be interpreted until you do.
invalid_message A known ug: type whose payload did not parse — you spelled it wrong. Fix the payload. If you use the SDK you should never see this: mount, navigate and setTheme throw at your call site first.
not_in_scope A ug:navigate outside the one case’s own views — that is not yours to see. Stop sending it. Do not retry, and do not treat it as a hint that the id exists somewhere else: a foreign document and a nonexistent one are the same refusal.
invalid_token A ug:theme token key or value that is not allow-listed. key names it. Fix that key. Note the whole tokens bag is still the complete override set, so re-post the full palette.
insufficient_contrast An allow-listed, legal token that would have put the record below WCAG AA. key names it. Adjust the pair and re-post a complete, coherent palette. The named key fell back to ours; the rest of the palette applied.

Note what is absent. There is no code for a refused session, an expired session, an unlisted origin or an unknown case. Those all arrive as a reason-less ug:session-ended — because the pinned origin at that moment is whatever origin posted ug:init, which on a refusal is quite possibly an origin the workspace never allow-listed. A reason there would rebuild, over postMessage, exactly the existence oracle the server’s response-identical refusal was careful not to be.

And there are no mutation-lifecycle messages, now or ever: the embed performs no writes, so there is nothing to signal.


The session lapses at about 30 minutes. This is the one lifecycle event every integrator meets.

There is no renew(handoff) and there cannot be: the coupon is exchanged once, during a handshake the frame runs as it boots. Tear the embed down and mount a new one.

async function renew() {
// YOUR backend, with your embed:mint key — the same call that produced the
// first coupon. A handoff is single-use; this is a new one.
const { handoff } = await fetch("/api/underwriting-handoff", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scenario_id: scenarioId }), // your backend knows the subject and the origin
}).then((r) => r.json());
embed.destroy(); // removes the listener and the iframe
embed = mountUnderwritingEmbed({ ...sameOptionsAsBefore, handoff });
}

You get 60 seconds’ notice with ug:session-expiring{expiresAt}, which is your cue. A host that ignores it gets the reason-less ended state instead. The frame cannot refresh itself — minting is API-key gated and server-to-server, and a refusal is terminal.

Two things that look like they should work and do not:

  • Reassigning iframe.src. load fires again, but ug:init is one-shot by design: the frame would receive nothing, and a second post could only ever carry a spent coupon.
  • Re-posting ug:init. The frame pins its host origin on the first well-formed one and ignores the rest. That is the containment property, not a bug.

Renewal after destroy() is a genuinely fresh frame, so your held theme and view have to be passed again — which is why the recipe above spreads the same options object.


Give the iframe a generous fixed height for the first round trip — the frame renders its neutral shell, exchanges its coupon and mounts the case before it can measure anything. The SDK defaults to 720 px, a laptop screenful.

After that the frame reports its own content height with ug:resize{height, bounded}, and the SDK follows it when autoResize is on (the default). The case view lays out cleanly from 360 px wide upward, measured at 360 / 600 / 900 / 1360 with zero horizontal overflow.

height is bounded at 2400 px, and bounded: true says the content is taller than that. Past the ceiling the case scrolls inside the frame rather than growing your page without limit, and a frame that regressed cannot steer you into a ten-million-pixel iframe. The SDK clamps to the same ceiling on receipt, so you get the guarantee whichever side you trust.

The consequence of following ug:resize: the iframe will routinely be taller than your reader’s window, and a frame taller than the window is one the reader scrolls the host page through. The frame handles its own overlays in that arrangement — it learns the slice of itself that is actually on screen, cross-origin, and pins the citation drawer inside that band, so clicking a citation opens the viewer in front of the reader whether the frame is bounded, taller than the window, or half-scrolled past. The frame learns nothing else about your page from this: not your scroll position, not your viewport, not where it sits on your page.

But the rest is yours, and there are two host obligations here:

  • Do not put the frame inside a short scrolling container of your own. The frame measures its band against the top-level viewport; it cannot see your inner scroller, and it will place its drawer relative to a band your container is clipping.
  • Do not overlay your own chrome on the frame — a sticky header, a floating action button. The frame cannot see it and will place its drawer under it.

If you would rather not deal with any of that: set autoResize: false, give the frame a fixed height that fits your layout, and let it keep its own scrollbar. The ug:resize events still reach onEvent if you want them.


8. The terminal state, and what to tell your user

Section titled “8. The terminal state, and what to tell your user”

When the session is over the frame renders one calm sentence:

This view is no longer available. Reopen it from your platform.

It may not name a cause, and that is deliberate. A refused exchange — a spent or expired coupon, a subject who is not an active user, a case that is not the workspace’s, an origin that was never allow-listed — and a genuinely lapsed session all land here, because a frame that distinguished them would be an oracle any page could query by framing us. It is also the kinder copy: the reader cannot act on the difference, and the remedy is identical.

What to tell your user: nothing about sessions. Give them the affordance the frame’s copy points at — your own “Open case” control — and re-run the handshake when they use it. Listen for ug:session-expiring and renew (§6) so most readers never see this state at all.

The frame has four other states, and the two you may notice while integrating:

state what the reader sees
waiting Blank. No logo, no spinner, no product name — this is what a hostile or unlisted page sees if it frames us, and it must carry no ambient authority and no branding.
no-handshake After ~10 s in waiting: “This view wasn’t opened from your platform. Reload it from there.” plus a console.warn naming the missing ug:init, the load event and targetOrigin. Since the pre-arm buffer landed, this state means what it says: nobody posted.
exchanging A quiet “Opening…”, still unbranded.
ready The one case, read-only.

The error page a refused frame gets is deliberately unbranded too: it has to be safe for an arbitrary page to frame, and a branded error inside a stranger’s site is a phishing prop.


The frame stays blank, and after ~15 s the SDK warns

Section titled “The frame stays blank, and after ~15 s the SDK warns”

The frame never reported ug:ready. Check the three causes, in order:

  1. This page’s origin is not in the workspace’s allowed sites. The most common first-run failure by a wide margin. Confirm with ug-admin embed allow-origin list --tenant <slug>, and remember the origin must match exactly — scheme, host and port. https://your-platform.example and https://your-platform.example:8443 are different origins.
  2. Embedding is off for this workspace — an empty allow-list, which is every workspace’s default.
  3. appOrigin/tenantSlug do not resolve to an embed document — a typo’d slug, or an appOrigin that is not the host serving /embed.

The app answers all three with the same plain 404 and frame-ancestors 'none', so there is no way to tell them apart from outside. That is why the SDK names all three rather than guessing.

Two other shapes of the same symptom, both entirely host-side and both of which the SDK throws for at your call site rather than letting you wait: a container that is not attached to the document (an iframe inside a detached element never loads) and an appOrigin with a path on it.

The exchange refused, or the session lapsed. See §8 for why you cannot be told which. Practical checklist, in the order that finds it fastest:

  • Was the coupon already used? A re-mount must carry a fresh handoff.
  • Was it more than ~60 s old when the frame exchanged it? Mint on the click, not ahead of time.
  • Is subject_email an active user in that workspace? An unknown or deactivated address is a refusal, never a provisioning event.
  • Does scenario_id belong to that workspace?
  • Is your page’s origin allow-listed? (Yes, this can land here too — which is why the list above starts with the coupon.)

Your own POST /api/handoff fails before the frame ever loads

Section titled “Your own POST /api/handoff fails before the frame ever loads”

The credential or the inputs are wrong at the source. Check that your key is present and carries the embed:mint scope, that it has not expired, and that subject_email, scenario_id and host_origin are well-formed (scenario_id must be a UUID and host_origin an exact https:// origin; a malformed one is a 422). The response body is the API’s own Problem+JSON and says which.

Watch ug:error in onEvent. invalid_token means a key or value the allow-list does not accept; insufficient_contrast means a legal pair that would have made the record unreadable. Both name the offending key and never its value.

Two non-error causes that surprise people:

  • You posted before ug:ready by hand. ug:theme is ignored entirely before the exchange. The SDK holds it for you; a hand-rolled host must wait.
  • tokens replaced rather than merged. Every setTheme carrying tokens replaces the whole override set, so a follow-up call with one key drops the other ten back to our values.

If you hand-rolled the host: a ug:navigate before ug:ready is ignored with no reply — “too early” is not “out of scope”, so you get silence rather than an error. Wait for ug:ready. The SDK’s ready gate means you will not hit this.


You need an https origin for your host page, because the allow-list stores https origins only and a browser will not let a plain-http page frame a secure one regardless (§2.2).

Terminal window
brew install mkcert # or your platform's equivalent
mkcert -install # once per machine: trusts a local CA
mkdir -p certs && cd certs && mkcert localhost 127.0.0.1 ::1

Then allow-list the host page’s origin (not the app’s) on the workspace:

Terminal window
ug-admin embed allow-origin add --tenant acme --origin https://localhost:8443

examples/embed-host/ is a runnable, framework-free host: a tiny Node backend that mints handoffs with a server-held credential, and a page that mounts one case using the SDK. Zero dependencies, no build step of its own. Its README carries the from-zero steps; the short version once you have certificates and an allow-listed origin is:

Terminal window
cd web && pnpm -F @ug/embed-sdk build # emits web/packages/embed-sdk/dist/
cd ../examples/embed-host
npm run vendor-sdk # copies dist/ into public/vendor/embed-sdk/
cp .env.example .env # fill in UG_EMBED_API_KEY at least
set -a; source .env; set +a
npm start # https://localhost:8443

It is the reference for the parts that matter — mint on the backend, frame the app host, post ug:init on load, and let the SDK do everything after that. It deliberately uses the SDK rather than hand-rolling a listener, so the sample cannot demonstrate a subtly weaker origin check than the one we ship.

A short list, because each absence is a decision you may otherwise spend time looking for:

  • No refusal reason. ug:session-ended carries none, and there is no error code for a refused session, an unlisted origin, an unknown workspace or an unknown case. Every refusal path — the embed document, the exchange, the frame’s terminal state — answers identically, so none of them is an oracle for what exists on the deployment.
  • No mutations, and so no mutation messages. No write, no approval, no comment, no upload, no export, no download. The frame composes no such control, and its fetch layer throws on a non-GET before the network sees it. One precision, because “no download” is about the UI rather than the credential: GET /v1/scenarios/{bound}/documents/{id}/download IS reachable with an embed session (it is a read of the bound case’s own sub-resource, and it is how the in-frame viewer fetches page images), so a session bearer lifted out of the frame can fetch that case’s document bytes. It reaches nothing beyond the one case, for the ~30 minutes the session lives.
  • No workspace configuration. You cannot read or change the allow-list, the workspace’s settings, its users, its question sets or its rules from inside the frame or over postMessage. Configuring a workspace is a human administrative act on the workspace’s own admin surface.
  • No progress or lifecycle events beyond the five listed. No per-query loading messages, no document-parse progress. ug:ready is the one “it is usable now” signal.
  • No identity surface. No account menu, no workspace switcher, no nav, no back link — the frame holds one case, and there is nowhere to go. Our identity surface does not belong inside your page.
  • No fonts, and no status colours, in theming. See §4.5.

  • Quickstart — the shortest path from a credential to a running underwrite.
  • Webhooks — being told when a background task finishes.
  • API reference — every route, request and response.