Apps
App Bridge
App Bridge
An embedded app runs in an iframe on its own origin. The browser keeps it out of the admin around it: it cannot show the admin's toasts, move the merchant to another admin page, change the title bar, or stop the merchant leaving with unsaved changes. App Bridge is how it asks the admin to do those things.
The App UI kit speaks the bridge for you. Load ui.js and you get the functions on this page, plus admin-drawn modals and save bars through <e-modal> and <e-save-bar>, and app navigation in the admin's sidebar through <e-app-nav>. The raw protocol is at the end, for apps that do not use the kit.
How it works
The bridge is window.postMessage between your frame and the admin, under the protocol name eekaam.bridge.v1.
- Requests go from your app to the admin. Each carries an
id; the admin answers with a response carrying the sameid. - Events go from the admin to your app, unprompted — the merchant switched to dark mode, or pressed a button in a modal you asked for.
Both sides check where a message came from:
- The admin accepts a message only if its origin is the origin your frame was loaded from, and only if it was sent by that frame's window. Anything else is ignored without a reply, so a stranger's page cannot drive the merchant's admin, or even learn that it is one.
- The admin sends responses and events only to your frame's origin. If the frame has navigated somewhere else, the browser drops the message.
- Your app accepts messages only from
window.parent. - Your app posts to the admin with target origin
"*", because it cannot know which admin host it is in. Nothing secret travels in that direction; the admin does the checking.
The admin only loads your frame at all if the origin of your embedded app URL is in your app's allowed origins.
Every call is safe outside the admin. When your app runs standalone — npm run dev on localhost — each request resolves at once with { ok: false, error: "not embedded" }, so you never have to guard a call site.
Functions
With the kit loaded, these are on window.eekaam. In React, import them from @eekaam/ui-react. With a bundler, import them from @eekaam/ui.
setAppLocation and openAdmin are new in kit 1.1. On a page still running a 1.0 ui.js, the React versions do nothing.
const { toast, navigate, setTitle, sessionToken, isEmbedded, onSchemeChange, setAppLocation, openAdmin } = window.eekaam;toast
toast(message, variant?) shows a message in the admin's own notification style. variant is "success", "error" or "info" (the default). The admin shows the first 200 characters and ignores an empty message. Returns nothing.
Use it for short confirmations — "Settings saved" — instead of drawing your own. See ui.native-overlays in the App review guidelines.
window.eekaam.toast("Settings saved", "success");
window.eekaam.toast("Could not reach the carrier", "error");setTitle
setTitle(title) sets the title the admin shows above your app. The admin keeps the first 120 characters. <e-page heading="…"> calls it for you unless you set keep-title.
sessionToken
sessionToken() resolves with a session token for your app's own backend, or null outside the admin.
Send it with every request from the frame to your backend, and verify it there with POST /admin/oauth/session/verify before you trust it — see Building an app. Never identify the store by a shop name in the URL; that is rule security.session-token.
Ask for the token when you need it rather than keeping your own copy. Today the admin hands back the token the frame was loaded with; asking each time means your app picks up fresh tokens without a change on your side.
async function api(path, init = {}) {
const token = await window.eekaam.sessionToken();
return fetch(path, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${token}` },
});
}isEmbedded
isEmbedded() returns true when the page is inside a frame. Use it to hide chrome you only need standalone. It does not prove the parent is an Eekaam admin; the bridge's origin checks do that.
onSchemeChange
onSchemeChange(listener) calls listener("light" | "dark") whenever the admin's colour scheme changes. It returns a function that stops listening. The kit already re-themes its own components; use this for things it does not draw, such as a chart.
const stop = window.eekaam.onSchemeChange((scheme) => chart.setTheme(scheme));setAppLocation
setAppLocation(path) tells the admin which of your app's pages is showing. Call it after every client-side navigation, with the path relative to your embedded URL: / for your home page, /settings for <embedded URL>/settings.
The admin updates its own address bar to /apps/open/<handle>/settings. It does not navigate or reload your frame. A reload, or a link the merchant shares, then opens the same page. The admin also marks the matching item in its sidebar as current.
The kit drops a query string or fragment before sending, because the admin would read them as its own. The admin refuses a path that is not a plain path inside your app — see App paths. Returns nothing.
To list your pages in the admin's sidebar, use <e-app-nav>. It sends the navMenu request for you.
window.eekaam.setAppLocation("/settings");openAdmin
openAdmin(intent, id?) opens one of the admin's own pages. The intent is an action and a resource, "edit:product" or "create:order". edit needs an id; create ignores it. Returns nothing.
| Intent | Opens |
|---|---|
edit:product | /products/<id> |
create:product | /products/new |
edit:order | /orders/<id> |
create:order | /orders/new |
edit:customer | /customers/<id> |
edit:collection | /collections/<id> |
create:collection | /collections/new |
There is no create:customer. It, and any other action or resource, is answered with { ok: false, error: "unsupported" }.
The id is 1 to 64 letters, digits, _ or -. A GraphQL gid such as gid://eekaam/Product/123 is accepted too, and cut to its last segment.
If the merchant has unsaved changes, the admin asks before leaving, as it does for navigate().
window.eekaam.openAdmin("edit:product", "gid://eekaam/Product/123");
window.eekaam.openAdmin("create:order");Modals
A modal drawn inside your frame covers only the frame. The admin can instead draw it over the whole window, the way its own confirmations look. The kit does this for <e-modal> whenever it safely can.
The text-only rule
The admin never renders your markup. An admin-drawn modal carries text only: a heading, a plain-text message, and up to three buttons. So <e-modal> is drawn by the admin only when all of these hold:
- the app is embedded,
- the only child elements are
<e-button>s in theprimary-actionandsecondary-actionslots, with at most two secondary — everything else is text, in-frameandloadingare not set, andsizeis notfullscreen.
The message is the modal's text with whitespace collapsed. Anything else — a form, a table, an image, a footer, a standalone page — draws inside your frame, exactly as before. Set in-frame to force that. If a modal the admin is drawing gains content it cannot draw, the kit hides it in the admin and shows it in your frame.
Actions
The kit maps your buttons to the admin's:
In your <e-modal> | In the admin |
|---|---|
The first <e-button slot="primary-action"> | The primary button. Label is the button's text |
variant="critical" on it | tone: "critical" |
Each <e-button slot="secondary-action">, in order | A secondary button (at most two) |
disabled on any of them, loading on the primary | The same state |
Change the heading, the message text, or a button — its text, disabled, loading, variant — and the kit sends modal.update. When the merchant presses an admin button, the kit clicks your matching <e-button>, so your click listeners run unchanged.
Pressing a button does not close the modal. Your code decides what happens, and usually ends by setting open = false, which hides the admin's modal.
<e-modal id="confirm" heading="Delete 3 products?" size="sm">
Deleted products can't be restored.
<e-button slot="secondary-action" variant="secondary" id="cancel">Cancel</e-button>
<e-button slot="primary-action" variant="critical" id="delete">Delete</e-button>
</e-modal>
<script type="module">
const modal = document.getElementById("confirm");
const del = document.getElementById("delete");
document.getElementById("cancel").addEventListener("click", () => (modal.open = false));
del.addEventListener("click", async () => {
del.loading = true; // the admin's button shows the spinner too
await deleteProducts();
del.loading = false;
modal.open = false;
});
</script>Closing and re-showing
Escape, a backdrop click and the admin's close button close the admin's modal straight away, then tell your app. The kit turns that into e-request-close with reason: "escape".
- If nobody prevents it, the modal closes, and
e-closefires. - If you call
event.preventDefault(), the kit shows the modal again. Use this while a save is running.
The admin draws one modal at a time. Opening a second admin-drawn modal replaces the first.
When the admin cannot draw it
If the admin does not answer modal.show within 800ms — an older admin — or answers ok: false, the kit draws the modal inside your frame. Your code does not change. e-open fires once the modal is showing, wherever it is drawn.
Save bar
<e-save-bar> tells the admin that a form in your app has unsaved changes. The admin then:
- shows its own "Unsaved changes" state, with Discard and Save,
- blocks the merchant from leaving the page — including a
navigate()from your app — exactly as for the admin's own forms, - with
discard-confirmation, asks "Discard all unsaved changes?" before discarding, - clears the state when the merchant leaves your app's page or the frame loads a new document. The kit also withdraws its modals and save bars on
pagehide, and when an<e-save-bar>is removed from the page.
When the merchant presses Save or Discard, the kit fires e-save or e-discard on your <e-save-bar>.
watch-form
Set watch-form to the id of a <form> and the bar manages itself:
| Happens | The kit does |
|---|---|
input or change inside the form | Opens the bar |
Save (e-save, unless you prevent it) | Sets saving, then form.requestSubmit() |
Discard (e-discard, unless you prevent it) | form.reset(), then closes the bar |
You end the save: open = false when it worked, which hides the admin's bar, or saving = false when it did not, which leaves it up. The admin shows its Save spinner only while saving is set, and ignores Save while it is. Without watch-form, set open yourself and handle both events.
Outside the admin, or with an admin that does not answer saveBar.show within 800ms, the kit draws a sticky bar at the top of its container, with the same two buttons, the same confirmation and the same events.
Forms with unsaved changes are expected to use a save bar. That is rule ui.save-bar.
<form id="settings">
<e-text-field name="greeting" label="Greeting"></e-text-field>
</form>
<e-save-bar id="bar" watch-form="settings" discard-confirmation></e-save-bar>
<script type="module">
const bar = document.getElementById("bar");
document.getElementById("settings").addEventListener("submit", async (event) => {
event.preventDefault();
const ok = await save(new FormData(event.target));
if (ok) bar.open = false;
else bar.saving = false;
});
</script>Theme sync
When ui.js loads inside the admin, it:
- sets the page to light straight away, because every admin that predates the bridge's theme message is light,
- sends a
themerequest, and switches to the scheme the admin answers with, - follows every
themeevent the admin sends when the merchant changes scheme.
The scheme is written to <html data-e-scheme="…">, which the kit's tokens read. Outside the admin the kit follows the operating system's prefers-color-scheme.
Fallbacks
The kit is written so that nothing breaks when the admin is missing or older than the kit.
| Situation | What happens |
|---|---|
| Not embedded (standalone, localhost) | Every request resolves { ok: false, error: "not embedded" }. sessionToken() is null. Modals and save bars draw in your page. The scheme follows the OS. |
| An admin that does not know a request | It never answers. The request resolves { ok: false, error: "the admin did not respond" } — after 800ms for modal.show and saveBar.show, 3 seconds for everything else — and the kit draws the modal or save bar in-frame. |
| An admin that knows the request but cannot do it | It answers { ok: false, error: "unsupported" }. Same fallback. |
| An admin without app navigation | navMenu, appLocation and intent are answered { ok: false, error: "unsupported" }, or not at all by an older admin. <e-app-nav> then draws its links in your page. |
| An admin without theme sync | The kit stays light. |
Protocol reference
For apps that do not load the kit. If you can load ui.js, do — it handles timeouts, origin checks and fallbacks for you.
Envelopes
| Message | Direction | Shape |
|---|---|---|
| Request | app → admin | { protocol, id, request: { type, … } } |
| Response | admin → app | { protocol, id, response: { ok, error?, … } } |
| Event | admin → app | { protocol, event: { type, … } } |
A request without an id is still acted on, but gets no response. Pick ids that are unique within the page.
// app → admin: a request
{ protocol: "eekaam.bridge.v1", id: "r1", request: { type: "toast", message: "Saved" } }
// admin → app: its response
{ protocol: "eekaam.bridge.v1", id: "r1", response: { ok: true } }
// admin → app: an event
{ protocol: "eekaam.bridge.v1", event: { type: "theme", scheme: "dark" } }Requests
type | Fields | Response |
|---|---|---|
ready | — | { ok: true }. Use it to check the bridge is there |
toast | message (first 200 characters shown), variant?: success | error | info | { ok: true } |
navigate | path — must start with /, not //, and contain no \ | { ok: true }, or { ok: false, error } for a refused path |
titleBar | title (first 120 characters kept) | { ok: true } |
sessionToken | — | { ok: true, token }, or { ok: false, error } when there is none |
theme | — | { ok: true, scheme: "light" | "dark" } |
modal.show | modalId (1–64 chars), heading (≤120), message (≤2000, plain text), size?: sm | md | lg, primaryAction?: { label (≤40), tone?: "critical", disabled?, loading? }, secondaryActions?: [{ label, disabled? }] (≤2) | { ok: true } |
modal.update | Same fields as modal.show. Ignored unless that modalId is open | { ok: true } |
modal.hide | modalId | { ok: true } |
saveBar.show | saveBarId (1–64 chars), saving?, discardConfirmation? | { ok: true } |
saveBar.hide | saveBarId | { ok: true } |
navMenu | items: [{ label, path }] — your app's pages. label is trimmed and cut to 40 characters; path is an app path | { ok: true }, or { ok: false, error: "items must be a list" } |
appLocation | path — the page showing now, an app path | { ok: true }, or { ok: false, error } for a refused path |
intent | action: edit | create, resource: product | order | customer | collection, id? | { ok: true }, { ok: false, error: "unsupported" }, or { ok: false, error } for a bad id |
Text longer than its limit is cut. An id that is missing or longer than 64 characters is refused with { ok: false, error }, as is an action without a label. Extra secondary actions beyond two are dropped. An unknown size becomes md.
A second modal.show replaces the modal on screen. Sending saveBar.show again with new fields updates the bar — use it to toggle saving.
In navMenu, an item without a string label, with an empty label, or with a refused path is dropped, and the first 10 that remain are kept. Each navMenu replaces the list. The admin keeps it for the browser session, so it survives a reload. It lists the items under your app in its sidebar, after any admin_page extensions, and links each to /apps/open/<handle><path>. An item with the path / links to your home page, /apps/open/<handle>, and the admin then adds no home row of its own.
For intent, see openAdmin for the page each one opens.
App paths
navMenu and appLocation take paths inside your app, relative to its embedded URL. The admin puts them in the frame's URL and in its own address bar, so it holds them to strict rules. A path must:
- start with
/, and not with//, - be at most 200 characters,
- contain no
\,?,#, whitespace or control characters, - contain no
.or..segment.
A path that breaks a rule is refused, not repaired.
Events
type | Fields | When |
|---|---|---|
theme | scheme: light | dark | The merchant changed the admin's scheme |
modal.action | modalId, action: primary | secondary:<index> | close | A button in your admin-drawn modal was pressed, or it was dismissed |
saveBar.action | saveBarId, action: save | discard | The merchant pressed Save or Discard (after confirming, with discardConfirmation) |
For modal.action:
primaryandsecondary:<n>leave the modal open.<n>is the zero-based index into thesecondaryActionsyou sent. Sendmodal.hidewhen you are done.closemeans the admin has already closed the modal — Escape, backdrop or ✕. To keep it open, sendmodal.showagain.
Without the kit
A minimal client, with the checks and the timeout the kit applies:
const PROTOCOL = "eekaam.bridge.v1";
const pending = new Map();
let counter = 0;
window.addEventListener("message", (event) => {
if (event.source !== window.parent) return; // only the admin
const data = event.data;
if (data?.protocol !== PROTOCOL) return;
if (data.event) {
handleEvent(data.event); // theme, modal.action, saveBar.action
return;
}
const resolve = pending.get(data.id);
if (resolve) {
pending.delete(data.id);
resolve(data.response);
}
});
function send(request, timeoutMs = 3000) {
if (window.parent === window) return Promise.resolve({ ok: false, error: "not embedded" });
const id = `r${++counter}`;
return new Promise((resolve) => {
pending.set(id, resolve);
setTimeout(() => {
if (pending.delete(id)) resolve({ ok: false, error: "the admin did not respond" });
}, timeoutMs);
window.parent.postMessage({ protocol: PROTOCOL, id, request }, "*");
});
}
await send({ type: "toast", message: "Saved", variant: "success" });
await send({
type: "modal.show",
modalId: "delete",
heading: "Delete product?",
message: "This can't be undone.",
size: "sm",
primaryAction: { label: "Delete", tone: "critical" },
secondaryActions: [{ label: "Cancel" }],
});Updated 17 September 2026