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.
1. What embedding is
Section titled “1. What embedding is”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-
GETbefore 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).
2. Prerequisites
Section titled “2. Prerequisites”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 acmeug-admin embed allow-origin add --tenant acme --origin https://your-platform.exampleug-admin embed allow-origin remove --tenant acme --origin https://your-platform.example--tenanttakes a slug or an id.liston 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-settingsis a FULL REPLACE. If you or the workspace’s own tooling calls it directly,GETthe document first, change the one field, andPUTthe whole thing back — sending onlyembed_allowed_originssilently clears the workspace’stime_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:
ug-admin mint-api-key --tenant-slug acme --name "your-platform embed" \ --scopes embed:mint --expires-in 24hThe plaintext key is printed once and cannot be recovered — only its hash is stored.
2.4 The frame URL
Section titled “2.4 The frame URL”https://app.example.com/embed/acmeThe 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.
3. The handoff
Section titled “3. The handoff”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} ----------------------|3.1 Your backend mints
Section titled “3.1 Your backend mints”POST /v1/embed/handoffsAuthorization: Bearer <your embed:mint API key>Content-Type: application/json
{ "scenario_id": "0198c5f2-4a10-7d22-8b19-9f1e2c3a4b5c", "host_origin": "https://app.acme.example"}{ "handoff": "<opaque token>", "expires_at": "2026-09-02T18:03:07Z" }subject_emailis 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_idis the one case this coupon opens. Also verified at the exchange, not here.host_originis the exacthttps://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 yourpostMessageequals 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 a422here.- The
handoffvalue 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:
- Authenticate the requester — your session, JWT or SSO middleware, the same gate your own pages sit behind. Never an open route.
- Take
subject_emailfrom 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. - Authorize the case — verify the subject may read the
scenario_idthey 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. - Defend against CSRF — this is a credentialed, state-changing POST, so it
needs whatever anti-CSRF your framework provides.
SameSitecookies 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:
targetOriginmust be the app origin, never"*".postMessage(msg, "*")works perfectly and broadcasts your single-use coupon to every frame on the page. Nothing turns red.- 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 load → ug: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.
3.4 A reload costs a full re-handshake
Section titled “3.4 A reload costs a full re-handshake”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.
4. Using the SDK
Section titled “4. Using the SDK”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.
4.1 How you get it, today
Section titled “4.1 How you get it, today”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.tsandsrc/theme-tokens.tsare the whole package. They import nothing but each other, so dropping them into your ownsrc/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 buildemits ESM plus.d.ts. If you serve it to a browser’s nativeimportrather than through a bundler, you must append.jsto every bare relative specifier the package emits (from "./protocol"→from "./protocol.js", and likewise eachexport *) — a bundler resolves them, a browser cannot.examples/embed-host/’snpm run vendor-sdkdoes exactly this and nothing else; every security-relevant line is untouched.
4.2 Mounting
Section titled “4.2 Mounting”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.
4.3 ready, readyTimeoutMs and onTimeout
Section titled “4.3 ready, readyTimeoutMs and onTimeout”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:
- this page’s origin is not in the workspace’s allowed sites
- embedding is off for this workspace
appOrigin/tenantSlugdo 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.
4.4 navigate
Section titled “4.4 navigate”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 caseThat 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 getsug:error{code:"invalid_message"}: you spelled it wrong. - A
scenarioIdthat is not the bound case, or adocumentIdthat is not one of that case’s documents —ug: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 keepsug:navigatefrom 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.
4.5 setTheme
Section titled “4.5 setTheme”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 borderaccent accent-hover accent-foreground accent-subtle accent-subtle-foregroundThree 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.
The value grammar
Section titled “The value grammar”A plain colour literal, in one of CSS’s numeric spellings, at most 32 characters:
#rgb,#rgba,#rrggbb,#rrggbbaargb()/rgba()with three number-or-percentage channels and an optional alpha, in either the legacy comma form or the modern space-plus-slash formhsl()/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.
tokens is the complete override set
Section titled “tokens is the complete override set”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.
Host tokens are mode-independent
Section titled “Host tokens are mode-independent”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/surfaceis 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
surfacewith notext-mutedof 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.
4.6 destroy
Section titled “4.6 destroy”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).
5. The message table
Section titled “5. The message table”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.
Host → frame
Section titled “Host → frame”| 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/tokens → invalid_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.
Frame → host
Section titled “Frame → host”| 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.
6. Renewal
Section titled “6. Renewal”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.loadfires again, butug:initis 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.
7. Sizing the frame
Section titled “7. Sizing the frame”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.
9. Troubleshooting
Section titled “9. Troubleshooting”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:
- 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.exampleandhttps://your-platform.example:8443are different origins. - Embedding is off for this workspace — an empty allow-list, which is every workspace’s default.
appOrigin/tenantSlugdo not resolve to an embed document — a typo’d slug, or anappOriginthat 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.
“This view is no longer available”
Section titled ““This view is no longer available””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_emailan active user in that workspace? An unknown or deactivated address is a refusal, never a provisioning event. - Does
scenario_idbelong 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.
A theme is not applied
Section titled “A theme is not applied”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:readyby hand.ug:themeis ignored entirely before the exchange. The SDK holds it for you; a hand-rolled host must wait. tokensreplaced rather than merged. EverysetThemecarryingtokensreplaces the whole override set, so a follow-up call with one key drops the other ten back to our values.
navigate seems to do nothing
Section titled “navigate seems to do nothing”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.
10. Local development
Section titled “10. Local development”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).
brew install mkcert # or your platform's equivalentmkcert -install # once per machine: trusts a local CAmkdir -p certs && cd certs && mkcert localhost 127.0.0.1 ::1Then allow-list the host page’s origin (not the app’s) on the workspace:
ug-admin embed allow-origin add --tenant acme --origin https://localhost:8443The reference host
Section titled “The reference host”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:
cd web && pnpm -F @ug/embed-sdk build # emits web/packages/embed-sdk/dist/cd ../examples/embed-hostnpm run vendor-sdk # copies dist/ into public/vendor/embed-sdk/cp .env.example .env # fill in UG_EMBED_API_KEY at leastset -a; source .env; set +anpm start # https://localhost:8443It 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.
11. What is deliberately not on the wire
Section titled “11. What is deliberately not on the wire”A short list, because each absence is a decision you may otherwise spend time looking for:
- No refusal reason.
ug:session-endedcarries 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-
GETbefore the network sees it. One precision, because “no download” is about the UI rather than the credential:GET /v1/scenarios/{bound}/documents/{id}/downloadIS 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:readyis 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.
Related
Section titled “Related”- 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.