A App Platform Development Docs
Jump to section

Start here

App Platform Development Docs

Build an app that extends the platform — orders, customers, storefront, admin, finance, and operations. There are exactly two app types. Pick the one that matches what you are building and follow this page top to bottom.

B

Remote / SaaS app (any language)

A Shopify-style install: OAuth PKCE, a scoped api_token, HMAC-signed webhooks, a GraphQL read+write API, an App Bridge SDK + UI kit to embed into the merchant admin, and a CLI that drives the whole dev loop. No PHP required — Next.js, Vue, Go, Python, anything.

Best for: SaaS products, AI integrations, embeddable UIs, third-party developers.

Start the Remote quick start →
A

PHP-native (in-process) app

A Composer package that ships inside the platform process. Deepest integration: register hooks/filters, contribute admin menu items, storefront sections, admin extension blocks, and a declarative settings form — all in PHP. Built on flashmandu/app-sdk.

Best for: first-party-style extensions, deep integrations, storefront sections, your own team.

Start the PHP-native quick start →
Remote app (Track B) In-process app (Track A)
Who hosts itYou, or the platform's app cluster.The platform, in its own process.
LanguageAnything that serves HTTPS.PHP 8.2+, a Composer package.
UI toolkitYour framework + the App Bridge UI kit (§5).Host layout + ui:: components + Flux (§3b).
Data accessScoped GraphQL + the bridge proxy allowlist.Platform ports, in-process, scope-checked (§3d).
InstallOAuth PKCE, per merchant.Operator registers the provider class; merchant installs.
DistributionOpen marketplace, after review.Vetted partners and first-party only.
Best forSaaS products, AI integrations, anything you already run.Deep integrations that must feel native and read host data hot.
Recommended for third-party developers: the remote track. It is the only track open to developers outside the platform operator's team, it works in any language, and it cannot be broken by a platform upgrade. Pick the in-process track only if you are the operator, or a partner the operator has explicitly onboarded.
Note. Every hook, scope, extension target, bridge message, GraphQL field and proxy path on this page is rendered from the live HookCatalog, Scope, PortScope, ExtensionTarget, app-bridge-host.ts, schema.graphql and the BridgeProxyController allowlist at render time. The page cannot drift from the code.

Start here

1a · Anatomy of an app

Before you write anything, know which pixels are yours. A merchant sees one screen; three different renderers produced it, and the boundaries decide what you can style, what you must ask the host for, and what you cannot touch at all.

Remote app (Track B)

Anatomy of a remote app in the merchant admin The admin chrome and sidebar are rendered by the platform. The title bar and save bar are rendered by App Bridge outside the app frame. The app surface inside the frame is rendered by the app itself. Platform admin Search Dashboard Orders Items Customers Reports APPS Loyalty Members Settings Unsaved changes Discard Save Members Add member Search members… NAME Asha Shrestha Bikash Rai Chandra Lama Platform admin Rendered by the platform App Bridge components Rendered by App Bridge outside your frame App surface Rendered by your app inside the frame

Rendered by the platform

Top bar, sidebar, profile switcher, the app's nav group. You never style these; you contribute their contents.

Rendered by App Bridge

Title bar, save bar, toasts and modals live outside your frame. You declare them over the bridge; the host draws them.

Rendered by your app

Everything inside the frame. Your framework, your HTML — and the host's look, if you use the UI kit in section 5.

In-process app (Track A)

Nothing is framed and nothing is bridged. Your Livewire components run in the platform process and render through the host layout, so the boundary is a code boundary rather than a pixel boundary: you get the host's components and its speed, and you carry the host's responsibilities with them.

Anatomy of an in-process app in the merchant admin The platform renders the entire screen, including the app's own screens, from the app's Blade and Livewire code running inside the platform process. Platform admin Dashboard Orders Items APPS Loyalty MenuItem, from your manifest Members Everyone earning points on this storefront. Add member x-ui::filter-bar NAME Asha Shrestha Bikash Rai Your screens Rendered by the platform, from your code No frame, no bridge

Rendered by the platform, from your code

Chrome, nav group and page body are one continuous host-rendered surface. Your Blade and Livewire produce part of it; the host draws all of it.

No frame, no bridge

There is no iframe to size and no App Bridge to declare chrome over. You call the host's Blade components directly — and inherit its upgrade cycle.

Remote app — the fastest path

2 · Quick Start — Remote App (Next.js)

Build a remote app when your code runs anywhere but here. The CLI scaffolds it, tunnels it, registers it against your sandbox profile and opens it inside the admin — no platform install, no PHP, no review round-trip while you iterate.

Five commands from npm install to a running embedded app against https://business-dev.autohisab.com.

a. Install the CLI

npm install -g @flashmandu/autohisab-business-cli

b. Log in

autohisab-business login
# ? Which environment are you logging into?
#   ❯ business.autohisab.com (production)
#     business-dev.autohisab.com (development)
#     Custom URL (local development)   → enter https://business-dev.autohisab.com
# Username / password: your account on this platform

Without flags, login prompts for an environment: production (business.autohisab.com), development (business-dev.autohisab.com), or a custom URL for local development — free-text input validated to be http(s). To develop against this box, choose the custom URL and enter https://business-dev.autohisab.com, or skip the prompt entirely with autohisab-business login --base-url https://business-dev.autohisab.com. The CLI POSTs your credentials to https://business-dev.autohisab.com/api/apps/dev/login and stores the returned bearer token in ~/.flashmandu/credentials.toml. Every login rotates the token.

c. Scaffold a Next.js app

autohisab-business init my-app --template next-js --app-id acme/my-app --name "My App"
cd my-app && npm install

Ships the OAuth PKCE handlers (app/oauth/authorize, callback, token) and an HMAC webhook verifier, pre-wired against sections 6 and 7. A --template php-remote variant is also available for a framework-free PHP remote app.

d. Run the dev loop

autohisab-business dev --tunnel cloudflared

One command does the whole local loop:

  1. 1Starts your app server (preflights the port).
  2. 2Opens a cloudflared quick tunnel — a public HTTPS URL. Required: the platform's SSRF guard refuses webhook URLs that resolve to private/loopback IPs.
  3. 3Registers/refreshes your app via POST https://business-dev.autohisab.com/api/apps/dev/register, substituting the tunnel URL into your manifest's authorize_url, token_url, webhook_url.
  4. 4Writes sandbox secrets to .flashmandu/sandbox-secrets.<app>.json (mode 0600, gitignored): api_token + webhook_secret.
  5. 5Opens the embedded preview at https://business-dev.autohisab.com/apps/<appId>/embed — the App Bridge shell that iframes your tunnel URL.

e. What is actually happening

Your tunnel URL becomes the app's webhook_url; platform domain events you subscribe to are POSTed there as HMAC-signed webhooks (section 6). The same URL hosts your app, which the merchant admin iframes through the App Bridge shell (section 4). Your api_token never reaches the browser — the bridge proxies authenticated reads server-side, and writes go through the GraphQL API. Edit a file, save, refresh; nothing leaves this box.

autohisab-business hooks    # every platform hook you can subscribe to
autohisab-business logs     # live tail of webhook deliveries to your app
autohisab-business whoami   # confirm your token + granted scopes

f. Not using Next.js? The framework-free PHP template

A remote app is just an HTTPS server the platform talks to — it does not have to be JavaScript. The php-remote template scaffolds the same OAuth + webhook contract with no framework and no build step. (It was called php-native until it was renamed for clarity — that name is still accepted, with a deprecation warning. "PHP-native" means the in-process track in section 3, which is a different thing entirely.)

autohisab-business init my-php-app --template php-remote --app-id acme/my-php-app --name "My PHP App"
cd my-php-app && composer install

# The CLI cannot guess how to boot a non-Node app, so tell it:
autohisab-business dev --tunnel cloudflared --app-command "php -S localhost:3000 -t public"

Everything else is identical to the Next.js loop. Two files to know:

  • •~/.flashmandu/credentials.toml — your CLI login token (mode 0600). Rotated on every login.
  • •.flashmandu/webhooks.log — every verified webhook delivery, appended locally so you can replay and diff payloads while developing.

Read your sandbox secrets from the environment — FLASHMANDU_API_TOKEN and FLASHMANDU_WEBHOOK_SECRET — never from a client-visible config. For UI, load the CSS kit from the CDN (section 5); the class contract is identical to the React components, so a PHP app renders the same admin look server-side.

Make it look native

2a · Building the UI

Your app runs in an iframe inside the merchant's admin. It should look like the rest of the admin, not like a different product. Two packages give you that for free — you do not write chrome.

@flashmandu/app-bridge

The protocol: iframe handshake, identity, host proxy, URL sync. Framework-free at its root; React helpers live under /react, Next.js wiring under /next.

@flashmandu/app-bridge-ui

The look: CSS tokens + component classes that mirror this admin, plus ~50 React components. PHP and Python apps use the CSS directly — the class contract is the portable surface, no JS required.

a. Wire the styles once

In app/globals.css:

@import "@flashmandu/app-bridge-ui/tokens.css";
@import "@flashmandu/app-bridge-ui/components.css";
Do not set data-fm-theme on <html>. Left unset, the tokens follow the merchant's light/dark preference automatically. Hardcoding it is the quickest way to ship an app that looks broken for every dark-mode merchant.

b. Page chrome — never hand-roll it

Declare your navigation once in lib/nav.ts. The active tab and the breadcrumb trail are derived from the current route, so pages only pass a title.

'use client'; // REQUIRED — createAppShell builds components at call time

import { createAppShell } from '@flashmandu/app-bridge-ui/next';
import { useBridgeTitle } from '@flashmandu/app-bridge/react';

export const { PageShell, AppTabs } = createAppShell({
  tabs: [
    { key: 'home',  href: '/',      label: 'Home' },
    { key: 'items', href: '/items', label: 'Items' },
  ],
  breadcrumbs: {
    '/items':          'Items',
    '/items/[id]':     (id) => `Item #${'{'}id{'}'}`,
    '/items/[id]/edit': 'Edit',
  },
  useTitle: useBridgeTitle, // publishes the page title to the host shell
});

Then every page is just:

import { PageShell } from '@/lib/nav';

export default function ItemsPage() {
  return (
    <PageShell title="Items" action={'{'}<a className="fm-btn fm-btn--primary" href="/items/new">New item</a>{'}'}>
      {'{'}/* your content */{'}'}
    </PageShell>
  );
}
The 'use client' on your nav file is not optional. createAppShell builds components when it is called, so it has to run in the client graph. Evaluated on the server, the returned functions are not registered client references and the page dies during data collection with a bare "i is not a function". Server Components can still import the result — that is an ordinary boundary crossing.

c. Row actions carry fixed meaning

These three colours mean the same thing everywhere on the platform. A merchant who learns “red = gone” in the admin must read the same thing inside your app — do not re-map them.

Class Colour Means
.btn-action-viewtealOpen / view publicly — leaves the admin surface
.btn-action-editblueEdit — reversible mutation
.btn-action-deleteredDestructive — always pair with a confirmation
<div class="fm-row-actions">
  <a class="btn-action-view"   title="Open in storefront">…</a>
  <a class="btn-action-edit"   title="Edit">…</a>
  <button class="btn-action-delete" title="Delete">…</button>
</div>

d. The class vocabulary

These are plain CSS classes — usable from React, Blade, Jinja or a string template. There is no Tailwind in your app unless you add it, so Tailwind utilities like w-full or space-y-3 will silently do nothing.

NeedUse
Page container / stack.fm-app-shell .fm-container .fm-stack
Card.fm-card .fm-card__header .fm-card__body
Buttons.fm-btn .fm-btn--primary .fm-btn--subtle .fm-btn--danger
Form field.fm-field .fm-field__label .fm-input .fm-select .fm-textarea
Table.fm-table .fm-table-wrap
Status.fm-badge--success/warning/danger .fm-callout
Empty / loading.fm-empty .fm-spinner .fm-skeleton
Nested drag list.sortable-container .sortable-item-wrapper .children-container

React equivalents are exported from @flashmandu/app-bridge-ui/react — Card, Table, Field, Modal, EmptyState, Badge, Dropdown, and more.

e. Every wait needs a visible state

Any click that hits the network must show feedback immediately — a spinner in the button, a skeleton in the region being filled. A dead click reads as a broken app.

<button class="fm-btn fm-btn--primary" disabled>
  <span class="fm-spinner fm-spinner--sm"></span> Saving…
</button>

Remote app

2b · Page chrome (breadcrumbs & actions)

Do not render a page heading on desktop. The platform admin puts the breadcrumb, the page actions and the dirty-state Save/Discard buttons in its own command bar. Your app declares them; the host renders them. An app page should be indistinguishable from a native one.

Breaking change. SET_TITLE and the UI kit's old <PageHeader> (the big in-page title) are removed — no deprecation window. Use usePageChrome() below. A desktop page that still renders its own <h1> now shows a duplicated title and will fail store review.

React

import { usePageChrome } from '@flashmandu/app-bridge/react';

export default function MapEditor({ map, dirty, onSave, onDiscard }) {
  usePageChrome({
    title: map.name,
    crumbs: [
      { label: 'Maps', path: '/maps' },
      { label: map.name },              // last crumb = current page, no path
    ],
    actions: [
      { id: 'discard', label: 'Discard', variant: 'ghost' },
      { id: 'save', label: 'Save map', variant: 'primary', loading: saving },
    ],
    dirty,                               // true ⇒ host swaps ⌘K search for Save/Discard
  });

  useBridgeAction(({ id }) => {
    if (id === 'save') { onSave(); }
    if (id === 'discard') { onDiscard(); }
  });
}

The wire contract

One message per route or state change — not three. The host coalesces bursts into a single animation frame and skips the write entirely when the payload has not structurally changed, so calling this on every render is cheap.

// iframe → host
{ type: 'FLASHMANDU_BRIDGE:SET_PAGE', payload: {
    title?: string,
    crumbs:  [{ label: string, path?: string }],   // max 5
    actions: [{ id, label, variant: 'primary'|'ghost'|'danger',
                icon?, disabled?, loading? }],      // max 4
    dirty: boolean,
} }

// host → iframe, when the merchant clicks one of your buttons
{ type: 'FLASHMANDU_BRIDGE:ACTION', payload: { id: 'save' } }

Limits are enforced host-side: labels max 48 chars, crumb paths must be app-relative, icons come from an allow-list (an unknown name renders no icon), and every string is inserted as text — never as HTML. A payload that breaks a cap is dropped whole, because the host cannot trust a partial one. Clicking a crumb routes through the same HOST_NAVIGATE + ack path as the sidebar, so your frame survives it.

Loading indicator

bridge.loading(true);   // host draws a progress bar under the command bar
await fetchThings();
bridge.loading(false);

Rate-limited to one flip per 100ms and auto-cleared after 15s, so a crashed app cannot leave the admin looking stuck. Pair it with skeletons (§2c) — never show a blank pane.

Mobile is different. The command bar is desktop-only (hidden lg:flex). Below the lg breakpoint there is no host chrome, so render your own compact header — the UI kit's <Breadcrumbs> and <MobilePageHeader> are already lg:hidden and do exactly this.

Remote app

2b.1 · Migrating from the pre-chrome kit

The compiler will not tell you that you need to migrate. If your app was built against the pre-chrome UI kit (0.2 or earlier), upgrading the dependency produces almost no errors — and leaves you with an app that still draws its own page header inside the frame. Read this before you decide the upgrade "went fine".

The whole compile-time breakage is two lines. Upgrading a real app (the Interactive Map reference app) from the 0.2 kit to 0.3 produced exactly one removed export:
lib/nav.ts(24,10):        error TS2305: Module '"@flashmandu/app-bridge/react"' has no exported member 'useBridgeTitle'.
lib/use-profile.ts(6,48): error TS2305: Module '"@flashmandu/app-bridge/react"' has no exported member 'useBridgeTitle'.

That is all. SET_TITLE folded into SET_PAGE, so useBridgeTitle went away — and nothing else did.

Why that is a trap. createAppShell, PageShell, AppTabs and SearchFilterBar still exist in @flashmandu/app-bridge-ui/next in 0.3. An app that keeps using them compiles clean against the new kit while remaining entirely pre-chrome: it renders its own big in-page title next to the host's command bar, and it will fail store review for the duplicated heading (§2b). The compiler is not your migration checklist — this page is.

The checklist

  1. Delete the createAppShell / AppTabs module. Tabs are not app chrome any more: the sidebar [[nav_items]] and the breadcrumb trail are the navigation.
  2. Delete every in-page <PageHeader> / desktop <h1>. After the migration the only <h1> left in the app should be <MobilePageHeader>'s, which is lg:hidden.
  3. Replace useBridgeTitle(…) with one usePageChrome({ title, crumbs, actions, dirty }) per route.
  4. Lift Save / Cancel out of your form. The form component stops rendering its own button row and instead exposes an imperative handle (submit() / discard()) plus a { dirty, submitting } state the page publishes upward. Keep a hidden submit input so Enter still works.
  5. Feed dirty from a real diff against a captured baseline, and re-baseline after a successful save — otherwise the host's dirty bar never clears.
  6. Swap window.confirm for the host modal and window.location.href for the bridge router, or your dialogs and navigations will be trapped inside the frame.

Where do actions go?

The command bar is for the state of the thing you are editing, not for navigation. The settled rule:

Action Where it renders
Save / Create / Cancel / DiscardHost command bar, via usePageChrome({ actions, dirty })
New / Add (list → create)In the page, top-right of the list
Preview, and any other navigationIn the page

Note: the CLI's Next.js starter template currently publishes its list's create action to the command bar. That is the older convention; the table above is the current rule. If you scaffolded from the template, move create in-page.

Known friction (kit issues, not your bug)

  • Breadcrumbs' linkComponent rejects next/link. The slot types href: string; Next types it string | UrlObject. Wrap Link in a three-line adapter that narrows href to string.
  • createMockHost() replaces globalThis.window with a bare EventTarget, which react-dom cannot survive (x instanceof window.HTMLIFrameElement throws). To render components against it, demote the fake to being only the app's parent: keep jsdom's window as the app window, point window.parent at the fake, give window.top a distinct object so the embedded check passes, and re-emit host→iframe messages onto the jsdom window.
  • FilterBar has no filter-pin / save-as-default, no single attached “Filter ▾” entry point, and no auto-derived quick-filter presets. Every native platform list has the first of those; no embedded app can currently match it.

Your app CSS should shrink

The 0.3 kit ships the platform restyle in tokens and classes: black primary (--fm-primary), tinted view/edit/delete row actions (.fm-btn--view/--edit/--delete), the segmented control and the black range slider (.fm-range). Delete any app-level --fm-primary override, any hand-rolled row-action styling, and any .fm-container / .fm-kv copies. Keep only what the kit genuinely does not ship — typically your webfont and, because the kit's tailwind.css is a separate opt-in import, a document reset.

Remote app

2c · Tables, filters & skeletons

Use the kit's IndexTable for any list. It reproduces the platform's own index tables exactly — sticky header, a frozen first column, and a horizontal scrollbar parked above the pagination — so your list looks native and behaves the way merchants expect.

import { IndexTable, FilterBar, SkeletonTable } from '@flashmandu/app-bridge-ui/react';

if (loading) return <SkeletonTable rows={10} />;   // never a blank pane

<FilterBar
  search={q} onSearchChange={setQ}
  filters={[{ id: 'status', label: 'Status', options: statuses }]}
/>

<IndexTable
  items={maps}
  selectable                              // adds the bulk-actions bar
  sort={sort} onSortChange={setSort}      // controlled — data stays yours
  columns={[
    { key: 'name', header: 'Name', pinned: true },   // frozen first column
    { key: 'areas', header: 'Areas', align: 'right' },
    { key: 'updated', header: 'Updated' },
  ]}
  footer={<Pagination page={page} onPageChange={setPage} />}
/>
Never wrap the table in overflow-x-auto. That turns the wrapper into a scroll container, so the sticky header resolves against it instead of the page and never pins. IndexTable handles horizontal scrolling for you with a clipped viewport plus a separate scrollbar strip. This is the single most common way to break the layout.

Scroll handling writes CSS custom properties directly and triggers no React re-render, so a wide table stays smooth at any row count. Inside your iframe the header pins to the top of your scroller — you have no command bar of your own to offset against.

See it running in the Showcase app: Data Browser /data

Navigation

2d · Sidebar nav & URL sync

a. Sidebar entries, declared at runtime

Declare your sidebar from inside your app. The host renders the entries under your app in the merchant's sidebar and persists them against the install, so they are right on every admin page — you do not re-run the CLI to change them. Mount <UrlSync /> as well (see c) and the host routes a click on one of them inside your already-loaded frame instead of reloading the admin around it.

import { useAppBridge } from '@flashmandu/app-bridge/react';

const bridge = useAppBridge();

// Declare the entries. Re-send whenever they change.
bridge.setSidebarLinks([
  { label: 'Home', path: '/', icon: 'home' },
  { label: 'Members', path: '/members', icon: 'users' },
]);

Static [[nav_items]] in flashmandu.app.toml still works and is still worth setting: it is the pre-boot default, the sidebar a merchant sees before your app has loaded once. Whatever your app declares at runtime replaces it.

# flashmandu.app.toml — the pre-boot default only.
[[nav_items]]
label = "Home"
path  = "/"
icon  = "home"
order = 1

[[nav_items]]
label = "Members"
path  = "/members"
icon  = "users"
order = 2

b. Subscribing is what makes it fast

The host will not suppress a sidebar click until your app has told it that it can route, and mounting <UrlSync /> is what tells it. Under the hood the subscription is the announcement: the first listener sends SET_CAPABILITIES with { hostNavigate: true }, tearing down the last one sends false, and the current state is restated once when CONTEXT arrives so a host that wired up late cannot miss it. Treat it as state, never as an event to count.

If you never subscribe, every sidebar click is a full page load. The host does not suppress the click, the browser navigates the admin, and your iframe is destroyed and rebuilt — script, handshake, session mint, data fetch — for what the merchant experiences as a tab switch. That is the intended fallback, not a bug: it is how apps built against older SDK versions keep working untouched. It is also what you get back the moment the component unmounts.
In dev, expect true → false → true. React Strict Mode mounts effects twice in development, so the capability announcement flips through that sequence on every page load. It is idempotent state, so the host lands in the right place either way — but it looks like a bug to anyone watching the messages go past.

Each navigate carries a host-allocated id, and your app acks that same id once a listener has received the path. The ack is the host's only evidence the navigation happened — it has already moved the address bar and the active highlight — so if none arrives within a short timeout it falls back to the real navigation the click would have done. You never handle the id yourself; the SDK echoes it for you, and your listener is handed only the path.

This all rides on parent_origin. The SDK accepts an inbound HOST_NAVIGATE only from the parent window on the resolved parent origin; with no origin resolved it fails closed and drops every one of them silently. The platform stamps parent_origin on the embed URL, so this only bites if you are framing an app by hand.

c. <UrlSync /> — both directions, one component

Render it once in your root layout. Your in-app navigation is then mirrored into the host address bar as ?path=/items&q=blue, so a merchant can bookmark, share and reload a view — including filters — and land back on it.

There are two UrlSync components. Both wire host navigation in both directions; they differ in whether you have to hand them a router.

ImportAddress bar (outbound)Sidebar clicks (inbound)
@flashmandu/app-bridge/next ✓ automatic ✓ automatic
@flashmandu/app-bridge/react ✓ automatic ✓ when you pass onNavigate

Mounting <UrlSync /> from @flashmandu/app-bridge/next wires host navigation in both directions: it mirrors the app's route into the platform address bar, and it subscribes to onHostNavigate so the merchant's sidebar clicks route inside the app — no per-app wiring, no manual bridge.onHostNavigate(...) call.

import { BridgeBootstrap } from '@flashmandu/app-bridge/react';
import { UrlSync } from '@flashmandu/app-bridge/next';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <BridgeBootstrap />
        <UrlSync />
        {'{'}children{'}'}
      </body>
    </html>
  );
}

The component at @flashmandu/app-bridge/react is router-agnostic by design: it takes pathname and search as props, so it has no router of its own to push to. Pass onNavigate and it subscribes to onHostNavigate for you, exactly as the Next adapter does. onNavigate is separate from onDeepLink because the two are different navigations: a deep-link restore corrects the current history entry (replace), while a sidebar click is a new entry the merchant can go back from (push).

import { UrlSync } from '@flashmandu/app-bridge/react';

<UrlSync
  pathname={'{'}location.pathname{'}'}
  search={'{'}location.search{'}'}
  onDeepLink={'{'}(path) => myRouter.replace(path){'}'}
  onNavigate={'{'}(path) => myRouter.push(path){'}'}
/>
Omit onNavigate and the component does not subscribe — deliberately. Subscribing announces the capability, and announcing it with nothing to handle the navigation would leave the merchant on a dead link, which is worse than the full-page navigation you get by leaving it off. So: pass it unconditionally, or not at all.

Subscribing is what announces the hostNavigate capability, and the host will not suppress a sidebar click until it has seen that announcement. An app that mounts <UrlSync /> late, or not at all, keeps today's full-page navigation — correct, but slow, and with no error to tell you why.

You do not declare which query params are shareable — the bridge decides, identically for every app:

  • ✓Shared by default. A param is in your URL because you put it there, which already says it is view state.
  • ✗Never shared: transport plumbing — session_token, parent_origin, app_id, profile_id, locale. Your session token is a live credential and must never reach the parent address bar.
  • ✗Opt out of sharing any param by prefixing it _ — e.g. _modal=open stays private to your iframe.

Storefront

2e · Storefront embed blocks

An embed block is a piece of your app the merchant can drop onto a storefront page from the page builder — a map, a booking widget, a calculator. It renders in a sandboxed iframe on the public storefront and speaks only postMessage.

a. Declare it

[[embed_blocks]]
type          = "price-calculator"
name          = "Price Calculator"
category      = "Tools"
icon          = "calculator"
hosting_tier  = "remote"
block_path    = "/embed/price-calculator"
capabilities  = ["identity", "resize", "navigate"]

  # Settings the merchant fills in from the page-builder inspector.
  [[embed_blocks.settings]]
  name     = "product_id"
  label    = "Product"
  type     = "select"
  required = true
  # Options fetched live from YOUR app, so the merchant picks real data.
  dynamic_options_source = "app"
  dynamic_options_path   = "/api/blocks/price-calculator/options/product_id"

b. Serve the dynamic-options route

The platform calls dynamic_options_path server-side when the merchant opens your block in the page-builder inspector, and renders the response as the dropdown's choices.

  • →Auth. The request carries Authorization: Bearer <token> — the same app-verifiable format as the storefront hop, signed with your App Secret (webhook_secret), so you verify it offline with no callback. Claims: profile_id, surface="admin", exp. Unlike the storefront token this one does name the profile — the merchant is configuring their own block and you must scope the list to them.
  • →Shape. Return either [{"{"}"value","label"{"}"}, …] or {"{"}"options": [ … ]{"}"}. Return [] (200) when the merchant simply has nothing published yet — the inspector shows a "nothing to choose yet" hint. Error objects are discarded, never rendered as choices.
  • →Caching. Responses are cached per profile for 60s (15s after a failure), so this endpoint is not hit on every keystroke.

c. Serve the block route

The platform loads {block_path}?session_token=…. Verify that token server-side — never in a client component, or you ship your App Secret to every visitor. Return self-contained HTML; keep it small, it is public storefront traffic.

d. Make it appear in the page builder

Three gates, all of which must pass:

  1. 1Registered. Run autohisab-business dev — the CLI pushes [[embed_blocks]] onto your install record.
  2. 2Merchant-enabled. Embed blocks are OFF per app by default. The merchant turns them on under Installed Apps.
  3. 3Plan-entitled. The profile's plan must allow app embed blocks.

Then: CMS Pages → edit a page → Add element → Apps → your block, and fill in the settings you declared. Nothing appears if any gate fails, and the picker does not explain which — check them in that order.

If an App Block element is already on the page but shows “Choose an app block”, it was placed before your block became available. Select it and pick your block from the list in the inspector — no need to delete and re-add. The same picker appears if a block is later withdrawn, so a page never gets stuck with an unconfigurable element.

Storefront

2e.1 · Style surfaces

An embed block renders in a sandboxed iframe, so the merchant's page-builder Design panel cannot reach inside it — which is why app blocks used to be the one thing on a storefront page the merchant could not restyle. A style surface is how you opt a piece of your block's chrome back in: you declare which CSS custom properties are editable, and the platform renders real inspector controls for them and pushes the values into your iframe.

a. Declare the surface and its slots

[[embed_blocks.style_surfaces]]
key           = "pin_card"
name          = "Info card"
description   = "The card that opens when a visitor taps an info pin."
preview_hint  = "open_first_info_pin"

  [[embed_blocks.style_surfaces.slots]]
  key     = "radius"
  label   = "Corner radius"
  type    = "length"
  var     = "--tours-card-radius"
  control = "radius"
  default = "12px"
  units   = [ "px", "rem" ]
  min     = 0
  max     = 48

  [[embed_blocks.style_surfaces.slots]]
  key          = "background"
  label        = "Background"
  type         = "color"
  var          = "--tours-card-bg"
  control      = "color"
  default      = "#ffffff"
  dark_default = "#161616"
  inherits     = "--fm-surface"
  themed       = true

b. Read your variables; never declare the platform's

Your var must be your own namespace. An app may read --fm-*; it may never declare one. The --fm-* tokens are the host's contract, and an app that redefines one stops tracking the merchant's theme the moment the platform changes it. This is enforced — style_var_prefix refuses a slot whose var is not yours.

Use inherits to say what your variable should fall back to when the merchant has not set it. In the example above the card background defaults to the theme's own --fm-surface, so a block dropped onto a dark storefront looks native before anybody touches the inspector. That is the difference between a block that participates in a theme and one that merely sits on it.

c. Theming

  • →themed = true gives the slot a light and a dark value, and the merchant edits both. Use it for anything whose contrast has to survive a theme switch — backgrounds, borders, text.
  • →dark_default is the dark-mode counterpart of default. Only meaningful alongside themed.
  • →Geometry — radius, spacing, width — is normally not themed. A corner radius that changes between light and dark is a bug, not a feature.

d. preview_hint

A surface the merchant cannot see is a surface they cannot edit. If the chrome you are exposing is only visible after an interaction — a card that opens on tap, a panel behind a toggle — preview_hint names the action the preview should perform so the thing being styled is actually on screen while they style it. Omit it when the surface is visible on first paint.

e. Receiving the values

Values arrive as CSS custom properties on your iframe's root, both in the initial context and on every subsequent edit, so the common case needs no JavaScript at all — write your CSS against var(--tours-card-radius, 12px) and it just tracks the inspector. If you need to react in code, the App Bridge exposes them as bridge.styleTokens with an onStyleTokens() subscription; see §4.

Storefront

2f · Storefront sections

A storefront section is a native page-builder section your app contributes — it lands in the merchant's section palette beside the built-in ones and renders as real composable nodes, not an iframe. Where an embed block is your app drawing its own pixels in a sandbox, a section hands the platform an element tree and lets the merchant's theme draw it.

Pick the right one. Choose a section when the output is content the merchant should be able to restyle, re-theme and edit in place (a listing, a card grid, a CTA band). Choose an embed block when you need to run your own JavaScript on the storefront (a map, a live calculator, a 3D viewer). A section cannot execute your code on the page; that is the whole point of it.

a. Declare it

[[storefront_sections]]
type           = "tour-highlights"
name           = "Tour highlights"
category       = "Tours"
source         = "blueprint"          # blueprint | static | endpoint
blueprint_file = "sections/tour-highlights.json"
schema_version = 1

  # Inspector controls the merchant fills in. Same shape as embed_blocks.settings.
  [[storefront_sections.settings]]
  name     = "heading"
  label    = "Heading"
  type     = "text"
  required = true

b. The three sources

source Requires Runs your server? Use it when
blueprint blueprint_file or inline element_tree No The section's shape is fixed at publish time and only its bound data varies. The default, and the only one that costs you nothing to run.
static — No Nothing is bound; the tree is the whole output.
endpoint endpoint_path Yes The tree itself depends on data only your app holds. The platform fetches it server-side, validates it, and caches the result.
The endpoint source is refused if it never answers. A storefront render will not block on your server: the platform serves the last good tree and refreshes out of band. An app that is asleep or slow degrades to stale content, not to a blank section — but an endpoint that has never returned a valid tree renders nothing at all. Prefer blueprint unless you genuinely cannot express the shape ahead of time.

c. What goes in the element tree

The tree is an array of composable nodes — the same vocabulary the page builder itself writes. The platform's AppSectionValidator owns that vocabulary and is the only authority on which node types and fields exist; a tree naming a node the platform does not know is refused at install time with the offending path.

This is deliberate, and it is why the CLI does not ship a copy of the node catalogue: a vendored copy would drift the moment the platform adds an element, and you would need a CLI release just to use it. The CLI checks the shape of your manifest and blueprint files; the host checks the vocabulary. Run section dev below to see the real verdict.

d. The authoring loop

# Watches your blueprint files and re-pushes on every save.
autohisab-business section dev

# Push once and exit (CI, or a one-off check).
autohisab-business section dev --once

# Point at an app directory other than the current one.
autohisab-business section dev --dir ./apps/tours

Each save replaces the app's declared set — the push is a replace, not a merge, so a section you delete from the manifest disappears from the palette on the next push. Validation errors come back naming the file and the JSON position, so a trailing comma tells you where it is rather than failing somewhere downstream.

e. What doctor checks

section-blueprint-valid runs offline as part of autohisab-business doctor. It verifies every blueprint_file exists and parses as a JSON array, and that a blueprint source has a tree while an endpoint source has a path. An app declaring no sections passes trivially. It says nothing about node types — see (c).

Both tracks

D · App design guidelines

This section is track-neutral. Everything below applies to a remote/SaaS app in Next.js and to a PHP-native in-process app equally. It is here — rather than inside one track — because design doctrine kept ending up in the wrong place: UI rules lived in the Next.js track, settings rules lived in the PHP track, and the best-written material of all sat in a GUIDELINES.md inside node_modules, where no developer was ever going to find it. One home, both tracks.

How this section relates to the kit's own guidelines. @flashmandu/app-bridge-ui ships a GUIDELINES.md that documents the kit at component level — every token, every class, every measured number and the host file it was measured from. That stays where it is and stays authoritative for values. This section is the doctrine: which surface a message belongs on, which empty state a screen owes the merchant, what a destructive action must say. Values there, decisions here.

D0 · The one rule — the host owns the chrome

An app page should be indistinguishable from a native admin page. Everything else in this section is a consequence of that. The load-bearing consequence is architectural, and it is settled:

The host draws the page chrome. Your app draws the middle. Title, breadcrumb trail and page actions are declared by your app through SET_PAGE / usePageChrome() and rendered by the admin's own command bar. It follows that an embedded app renders no desktop page heading at all — no <h1> in the app document above lg. Below lg the host command bar is hidden lg:flex and does not exist, so the app renders MobilePageHeader inside the frame instead. Those are the only two states; there is no third.

This is not a preference to be re-argued per app. It is the platform's stated design thesis — see docs/superpowers/specs/2026-08-11-design-system-polaris-parity-spec.md §1, which sets out why the kit ships no "show the title anyway" escape hatch: an option that can be turned off will be turned off, and the result is two headings stacked. The kit's PageHeader was deleted in 0.3.0 for exactly that reason, with no deprecation shim — importing it is a build error, which is the point. The corollary for the mobile header: it is the app's only heading, so it is where the accessible page name lives.

Two more consequences worth stating plainly, because apps violate them by accident: primary workflows finish inside the frame — an app that sends the merchant to an external dashboard to paste an API key has not integrated, it has linked; and you get platform updates for free only if you use the kit — a hand-rolled clone of an admin surface is correct exactly once and then drifts. "Our brand" is not a reason to deviate.

D1 · The layer rule — and why your scaffold must not define components

The design system has four layers with one ownership rule each. Most "our app looks wrong" bugs are not missing components; they are components sitting in the wrong layer.

Layer What lives there Owner
L1 · Tokens--fm-* — colour, type, spacing, radius, shadow, plus the composition tokens (--fm-card-padding, --fm-control-height) and both themes@flashmandu/app-bridge-ui
L2 · PrimitivesText, Icon, Button, Input, Field, Card, Badge, Popover@flashmandu/app-bridge-ui
L3 · CompositionPage, Tabs, Card.Section, IndexTable, FilterBar, EmptyState, the skeletons@flashmandu/app-bridge-ui
L4 · PatternsWhole screens: index → detail, settings, empty & error states, destructive action, long-running sync, third-party connectionThis page + the Showcase app. No code artifact.
A scaffold template may compose L3. It may never define L3. This is not an abstract tidiness rule — it is the direct cause of the "every scaffolded app looks wrong" complaint. The CLI template used to carry its own AppPage component and its own globals.css overrides of the kit's classes. A page shape that lives in a scaffold is copied once and then diverges per app, forever, and an override in the scaffold's stylesheet silently outranks the kit on every screen the app will ever have. Both apps built on that template forked the component and inherited the override. What this means for you: your generated globals.css should contain layout-only rules and your font declaration (--fm-font-sans) — nothing that restyles a .fm-* class, and no raw hex, spacing or radius literal. If you find yourself writing .fm-btn { … }, the change belongs in the kit, not in your app. Audit the file the day you scaffold; it is much harder later.

D2 · Empty states — three kinds, and they are not interchangeable

An empty screen is a screen you shipped, and merchants read it literally. There are three distinct empty states and using the wrong one is a real failure, not a polish issue.

Kind When it is correct What it says Its action
Onboarding — no data yet The collection has never held a record An icon, a heading naming the thing, one sentence on what it is for — why the merchant would want one A prominent primary action that creates the first one
No results for this filter The collection has records; the current search/filter matches none One short line stating that nothing matched. No icon, no evangelism Clear filters / adjust the search. Never "create one"
Feature activation The feature exists but is off or unconfigured What turning it on does The action that enables or configures it
The decisive rule: choose the empty state from whether the unfiltered collection is empty — never from whether the current result set is empty. Collapsing kinds 1 and 2 into one component is the single most common index-page bug: a merchant with 4,000 orders filters to a status that has none of them and is told "Get started by creating your first order", which reads as data loss.
// The branch that decides it. `totalCount` is the UNFILTERED count and must
// come back from the server alongside the page of rows — you cannot derive it.
if (rows.length === 0) {
  return totalCount === 0
    ? <EmptyState icon={<MapIcon />} title="No maps yet"
        description="Maps let a customer find your outlets from a storefront page."
        action={<Button variant="primary" onClick={create}>Create your first map</Button>} />
    : <EmptyState title="No maps match these filters"
        action={<Button onClick={clearFilters}>Clear filters</Button>} />;
}
  • ›Never render an empty table body. A table with a header and no rows reads as a broken request.
  • ›Copy is concrete and action-oriented — "Create your first map", never "No data".
  • ›Known gap, and it is yours to handle today. The kit ships one EmptyState and no no-results variant; IndexTable's built-in fallback is the bare string "No results". Nothing in the kit or in these docs previously described the second kind at all, so every filtered index shipped so far defaults to getting it wrong. Until a variant lands, pass your own emptyState to IndexTable using the branch above.

D3 · The error ladder — inline → banner → toast → modal

Severity picks the surface. Each rung has exactly one correct answer, and picking a higher rung than the situation deserves is as wrong as picking a lower one.

Rung Use it for In the kit Never
1 · Inline error One field is invalid <Field error="…"> → .fm-field__error A toast for a validation failure — it vanishes before the merchant finds the field
2 · Banner / callout A condition on the page: degraded integration, a required setup step, a plan limit reached, a form whose errors are off-screen <Callout variant="danger|warning|info|success"> Confirming a save. Stacking three at the top of a page
3 · Toast Confirming an event the merchant just caused, that succeeded, needing no action, explainable in a few words useToast() / bridge.toast() — the host's own toast pipeline (§4) Anything they may need to re-read, anything requiring a decision, anything that fires when they were not looking
4 · Modal A decision that must be made now: destructive confirmation, a focused sub-task, a severe blocking error Modal / useConfirm(), or the host's declarative modal (§4a) Marketing. Anything the page could have said inline. Anything opened without a merchant action
  • ›Every write reports its outcome — create, update, delete, reorder, both branches. Say what succeeded ("Map saved"), never "Done". Never fire a success toast for a no-op. The full rule and the failure-branch example are in §4.
  • ›An inline error must say what is wrong and how to fix it. Plain language, no bare error codes, no blame, always a next step.
  • ›If a form's inline errors are scrolled off-screen, add a summarising callout at the top of the form. Both, not either.
  • ›A modal must follow a merchant interaction. Never open one on route entry or on a nav click.
  • ›Warnings do not survive the toast transport today. The bridge maps a warning toast onto the host's informational renderer, so it arrives looking like neutral information. Route real warnings to <Callout variant="warning"> deliberately rather than discovering this in production.
  • ›A toast carries no action. The transport forwards message and variant only, so there is no Undo affordance. Anything that needs one must not be a toast.

D4 · Destructive actions

Destruction is the one place where a merchant's mistake is not recoverable by clicking again, so the confirmation carries real weight and has a fixed shape.

  • ›Always a modal, via useConfirm(). Never an inline "are you sure", never a browser dialog (see D5).
  • ›Name the resource in the title — "Delete Q3 report?", not "Delete item?". The merchant must be able to tell from the modal alone that they clicked the right row.
  • ›State what is lost and whether it is recoverable. "This also removes its 4 saved views. This cannot be undone."
  • ›Label the confirm button with the verb — "Delete", not "OK" or "Yes". It is the destructive-styled button; Cancel takes default focus and is the escape hatch.
  • ›Bulk destruction names the count — "Delete 23 maps?" — and the count is the one that will actually be deleted, not the one that is selected on this page.
  • ›Keep destructive entry points away from constructive ones. Bottom of a detail page, or last in an overflow menu, visually separated — never adjacent to Save. Row-action colour semantics are fixed platform-wide (.btn-action-delete is red and means destructive); do not re-map them.
  • ›Uninstall is not a delete button. onUninstall must clean up your app's own state and leave the merchant's business records intact; reinstall must be idempotent.

D5 · confirm() is blocked in the frame — and fails silently

The embed shell frames your app with a sandbox attribute that deliberately omits allow-modals. Browsers block native dialogs in such a frame — and a blocked confirm() does not throw. It returns false.

// BROKEN inside the embed — returns early every single time, forever.
if (!confirm('Delete this?')) return;

So the delete never runs, with no error, no toast and nothing in the console. It is the failure mode that looks most like "the platform is broken" and is hardest to find, which is why it gets its own rung here rather than a footnote. The same applies to alert() and prompt(). Use useConfirm() — a promise-based dialog that reads like confirm() at the call site and renders as an admin modal. The worked example is in §4.

D6 · Loading and skeletons

Never a blank pane. Never a dead click. Every interaction that hits the network shows feedback in the same frame the click lands in. Which feedback depends on what is waiting:

What is waiting What to render
A whole route, first paint<SkeletonPage /> — header, filters and table placeholders in the eventual layout
A table's rows<IndexTable loading /> or <SkeletonTable rows={10} />
A block of copy or a detail pane<SkeletonText lines={3} />
One in-flight action<Button loading> — the button goes loading and disabled. Never a page-blocking overlay for a row-level save
A host-level wait (navigation, long sync)bridge.loading(true) — the admin's own progress strip; throttled to 100ms and auto-cleared at 15s so a crashed app cannot leave the admin looking stuck
A command-bar actionactions[].loading on usePageChrome(); the host mirrors it
  • ›Skeletons preserve the eventual layout. The point is not decoration, it is that nothing jumps when the data lands. A spinner in the middle of an empty box guarantees a jump; a skeleton that matches the table's column widths does not.
  • ›Reserve space for async media — an image with no declared box is a layout shift waiting for a slow network.
  • ›Skeleton animation must be suppressed under prefers-reduced-motion. The kit's own skeletons already do; anything you hand-roll must too.
  • ›Do not chain a skeleton into a second skeleton. If a route paints placeholders and then swaps them for different placeholders, fetch in parallel instead.

D7 · Where the numbers live — and the one rule that keeps them there

Geometry lives in exactly one file: bridge-ui's components.css. Blade and React are two thin markup layers over it. The host's own filter bar (packages/flashmandu/ui-components/…/filter-bar.blade.php) and the kit's React <FilterBar> emit the same .fm-* class names and own no geometry of their own. Every size, radius, inset, hairline and colour comes from the one stylesheet, vendored into this repo at resources/css/vendor/app-bridge-ui/ by scripts/sync-bridge-ui-css.mjs. So: never add a Tailwind utility to an element carrying an .fm-* class. If a measurement is wrong, change the token in the kit and re-sync. The host's tests/Unit/FilterBarClassContractTest.php fails the build when a geometry utility creeps back on — box model, sizing, border/radius/shadow, colour, type, flex/grid, positioning and z-index are all banned next to an .fm-* class. The narrow exception is a visibility decision the contract does not make (hidden sm:flex on the actions slot); utilities sit in a later cascade layer than the kit's components layer, so that still wins, by design.

This is the D1 layer rule with teeth. A scaffold template may compose L3; it may never define it — and the corollary is that it may never restyle it either. Every number below is read out of the vendored kit, and each one cites the token that holds it, because the token is what you should consume: a density change is then one edit, not a search-and-replace.

D8 · The index page — region order and filter-bar anatomy

An index page has a fixed region order, top to bottom. It is fixed because a merchant moving between a native admin page and an embedded app must not have to re-find the controls:

host command bar          ← NOT yours (title, breadcrumbs, form actions). Absent below lg.
─────────────────────────── iframe starts here
.fm-page-actions          ← the page's own buttons, right-aligned, optional
.fm-tabs                  ← segmented control, genuine sub-views only, optional
.fm-filter-bar            ← search + filters + saved default, then the chip row
.fm-index-shell / table   ← the data
  .fm-index-footer        ← pagination, sticky to the bottom of the shell
.fm-bulk-bar              ← appears on selection, sticky above the viewport bottom

.fm-container owns the rhythm between those regions — display: flex; flex-direction: column; gap: var(--fm-section-gap) = 24px, and the page box itself is 24px top / 16px sides / 128px bottom below lg, and 16px / 24px / 40px from lg up, capped at --fm-content-max = 1280px and centred. (.fm-container--narrow = 768px for a single-column form; --bleed drops the gutter for a full-bleed canvas.) The 128px bottom is not decoration: below lg the host paints a fixed mobile bottom nav that overlaps the embed iframe, and an app that stops at 40px puts its last row underneath it. Because the container owns the gap, a region must not add its own margin — that is exactly what put a different gap above the table on every page that used tabs.

Filters live above the data, never in a drawer

  • ›Filters are page state, and page state must be visible. A drawer hides which filters are on, so a merchant reads an empty result as missing data (D2's decisive rule). The applied set stays on screen as chips, and each chip is a button that re-opens the popover that set it.
  • ›The bar owns search, filtering and the saved default. The table header owns sort. Sort is a property of a column, so it belongs on the column; putting it in the bar means a merchant sorts in one place and reads the result in another. Pagination belongs under the data, in .fm-index-footer.
  • ›Selection does not replace the header. Our bulk-actions bar is a floating pill — position: sticky; bottom: 1rem, width: fit-content, centred, pill radius, --fm-z-sticky — so the column headings, the filter chips and the row count all stay readable while a bulk action is being chosen. Do not hand-roll a header swap; it costs the merchant the context they are acting on.
  • ›A page with no free-text search still renders the identical well. The host's bar gives it a look-alike input that opens the filter menu on focus and flashes a one-line hint when typed into, so the control never reads as broken.

Anatomy, as the kit measures it

The bar is two stacked rows — the control row, then the chip row — in a column flex with an 8px gap (--fm-space-2) and a 16px bottom margin (--fm-stack-gap). .fm-filter-bar__row is the only position: relative in the bar, so the filter menu and every field popover hang off the bottom of the control row rather than off the whole bar or a per-field trigger.

Part Class Geometry
The well .fm-filter-bar__group ONE bordered box holding [Filter ▾][search][×][pin]. flex: 1 1 18rem (288px basis), min-width: 12rem (192px), 1px border, 8px radius (--fm-control-radius), --fm-shadow-sm. The focus indicator lives here, on :focus-within — a 3px --fm-primary-ring — not on the input
Filter button .fm-filter-bar__filter 40px tall (--fm-control-height — same as the input it is welded to), 12px side padding, 6px icon gap, weight 500. Left corners are the group's; its right edge is a 1px divider, not a border
Active count .fm-filter-bar__filter-count Inverted pill: 20px tall, min-width: 20px, 4px side padding, 11px/600 (--fm-text-eyebrow), --fm-primary on --fm-primary-contrast
Search input .fm-filter-bar__search-input 40px tall, 12px side padding (--fm-field-padding-x), transparent, borderless, outline: none on focus — the well shows the ring. Fills the group (flex: 1 1 auto; min-width: 0)
Clear search .fm-filter-bar__clear-search 28px square — a 16px glyph (--fm-control-icon) on a 6px inset — 6px radius, align-self: center so it stays square inside a stretch group
Saved-default pin .fm-filter-bar__pin 36px wide, 1px left divider, right corners rounded. Two states and no third: muted = "you could save this"; --set = filled, --fm-success on --fm-success-subtle = "this IS your default"
Trailing slot .fm-filter-bar__trailing margin-left: auto, 8px gap, wraps. This is where a tab strip or a print button goes — not more filters
"Filter by" menu .fm-filter-bar__panel 240px wide (--fm-filter-panel-width), top: calc(100% + 4px), capped at calc(100vw - 24px). Items are 6px × 12px
Field popover .fm-filter__popover 288px wide, 12px padding, same 4px offset. One open at a time. Options are 6px × 10px; the selected one carries .is-active
Chip row .fm-filter-chips One chip per active filter — a label button that re-opens its popover plus an × that removes the group — then a single "Clear all"

Both overlays sit on --fm-z-popover. That is a named rung on the kit's stacking ladder (base 0 · raised 10 · sticky 20 · popover 30 · drawer 40 · modal 50 · toast 70 · tooltip 100), and it is a token so an embedding app can move a whole tier in one line instead of overriding selectors — the host admin's own command bar is z-30 and simply sets --fm-z-popover: 20 on :root.

D9 · Action placement

Two homes, and the split is by what the action is about, not by how important it feels:

Home What belongs there How you declare it
Host command bar Actions about the page as a whole: Save, Discard, the one primary create. These sit beside the title and breadcrumb trail, exactly where a native admin page puts them usePageChrome({ actions }) → SET_PAGE
In-page action row Actions about the content: export this table, add a row, open a picker. Also anything the command bar caps out .fm-page-actions — the first child of the container
Row / bulk Actions about one record or a selection Row action buttons; .fm-bulk-bar for a selection

.fm-page-actions is the host's own header row with the title cell empty: justify-content: flex-end, wraps, 8px gap (--fm-stack-gap-tight), and zero margin — the 24px to the content below is the container's section gap, and a margin here would add a second, competing one. .fm-page-actions--start left-aligns it for mobile, where the row sits beside the in-frame heading rather than under an absent command bar.

Known gap, and it changes what you should ship today: chrome actions render nothing below lg. The host's command-bar app slot is hidden … lg:flex, and MobilePageHeader takes an actions node it renders verbatim — it has no knowledge of usePageChrome. An app that declares Save through the documented path has a working Save on desktop and no button at all on a phone. Until <Page> lands and owns both, pass the same actions to MobilePageHeader yourself, or put the action in .fm-page-actions where one declaration covers both widths.

The caps the host enforces on SET_PAGE, which are validated as a whole payload — an invalid one is rejected entirely, with the reason:

  • ›4 actions maximum, 5 breadcrumbs, a 120-character title and 48-character action labels. Four flat actions is the whole budget — there is deliberately no in-frame overflow menu for page-level actions, because a ⋯ holding page actions is page chrome reintroduced inside the frame.
  • ›Six action icons exist, and an unknown name is dropped in silence while the action still renders: check, plus, trash, arrow-path, pencil-square, arrow-down-tray. So a wrong icon renders as no icon, not as an error. (arrow-path is also the loading spinner, so a refreshing Refresh and an idle one are one glyph apart.)
  • ›Chrome icons are Heroicons, and so is the sidebar's [[nav_items]] vocabulary — not Tabler. A Tabler name resolves to nothing rather than throwing. Only app and embed-block icons use the Tabler catalogue.
  • ›Destructive actions stay away from constructive ones (D4), which in practice means the bottom of a detail page or the end of a row menu — never the fourth slot beside Save.

D10 · Card, table and tab numbers

Every value below is a token in tokens.css, and every token cites the host surface it was measured from. Consume the token, never the number: that is what makes a density change one edit, and what lets a host-parity guard prove the kit still matches Flux.

Cards

  • ›24px padding, 12px radius, 1px border (--fm-card-padding, --fm-card-radius) — Flux's <flux:card> p-6 rounded-xl, plus a near-flat two-layer shadow.
  • ›The nested step-down is one step and it is named: .fm-card--sm = 16px padding, 8px radius (Flux size="sm"). It is the only sanctioned card-inside-a-card treatment. Do not invent a third size, and do not reach for a subdued card as de-emphasis — the muted tone is already spent on headers and footers, so a subdued card reads as a header that swallowed its body.
  • ›Padding is one knob. --fm-card-pad drives the body, the header's negative-margin bleed and the footer's mirrored inset together, so a density change can never leave a header edge that no longer lines up with the body beneath it. Header and footer are 16px vertical padding (--fm-card-header-padding-y) on --fm-surface-muted with a 1px hairline; the footer right-aligns its actions with an 8px gap.
  • ›Sibling cards sit 16px apart (--fm-card-gap); page sections sit 24px apart. Title is --fm-text-base/600, subtitle --fm-text-sm muted, 2px below it.

Tables

  • ›Body cells: 14px vertical, 16px horizontal (--fm-table-cell-padding-y/-x), vertical-align: middle, a 1px bottom hairline that is dropped on the last row, and a --fm-surface-muted row hover.
  • ›Column headings: 12px vertical padding, 11px/600 uppercase with 0.05em tracking, muted, on a muted band. Note the selector — .fm-table thead th. The thead qualifier is load-bearing: an accessible table gives its first column <th scope="row">, and a bare th rule shouted every row label in 11px uppercase. Row headers are body cells that happen to be semibold.
  • ›Exactly two table shapes exist — IndexTable (selectable index) and Table (plain, plus a totals row). There is no third, and the pagination strip under a table is 16px on both axes (--fm-index-footer-padding-*), sticky to the bottom of the shell.
  • ›Empty state: 48px vertical padding, 16px horizontal, a 48px glyph (--fm-empty-*), then the heading, the sentence and the action. The kit's earlier 32px/40px version read as a faint three-quarter-scale copy of the host's. Which kind of empty state to render is D2, and it is the more important half.

Tabs

  • ›Tabs are a segmented control, not a nav bar. .fm-tabs is an inline-flex with a 4px inset, a 1px border and an 8px radius; segments butt against each other (gap: 0). The old bottom-border tab nav is gone because it read as a second navigation system beside the host sidebar.
  • ›Each segment is 32px tall (--fm-control-height-sm) with 12px side padding and a 6px radius. The active segment fills with --fm-primary and keeps weight 500 — bolding on selection re-measures the label and nudges every segment to its right by a pixel or two on each switch.
  • ›The strip carries no margin. It lives in the filter bar's trailing slot or as a container child, both of which already own the rhythm. .fm-tabs--block stretches it full-width with equal segments.
  • ›Tabs are legitimate only for genuine sub-views of one page. Page-level navigation belongs to the host sidebar and the command bar's breadcrumbs. If a tab would change the page's title, it is not a tab.

The one breakpoint

The kit changes layout at 1024px and only 1024px — published as --fm-bp-lg so an app never guesses. It is the same lg at which the host swaps its command bar for the in-frame mobile header, which is why one number covers both. Five breakpoints and per-breakpoint responsive prop objects are on the explicit do-not-build list. Two rules in the kit currently break at 900px (.fm-form-grid) and 768px (.fm-stat-grid) — those are known violations of this policy, not precedent.

PHP-native — in-process

3 · Quick Start — PHP-Native App

Build an in-process app when you are the operator or a vetted partner. It is a Composer package the platform boots: your Livewire screens render in the host layout, your hooks fire inline, and you read host data through ports instead of HTTP.

Two classes do the work: a Manifest that implements AppProvider and an optional Hooks class that implements AppHooks.

Step 1 — Require the SDK

composer require flashmandu/app-sdk

The SDK is host-agnostic: no Laravel or platform dependency, so your app can be unit-tested in isolation.

Step 2 — Implement AppProvider

<?php

namespace Acme\Loyalty;

use Flashmandu\AppSdk\Admin\AdminUI;
use Flashmandu\AppSdk\Admin\MenuItem;
use Flashmandu\AppSdk\AppManifest;
use Flashmandu\AppSdk\AppProvider;
use Flashmandu\AppSdk\Scope;
use Flashmandu\AppSdk\Settings\SettingField;
use Flashmandu\AppSdk\Settings\SettingsSchema;

final class Manifest implements AppProvider
{
    public function manifest(): AppManifest
    {
        return new AppManifest(
            id: 'acme/loyalty',
            name: 'Acme Loyalty',
            version: '1.0.0',
            scopes: [Scope::ReadOrders, Scope::ReadCustomers],
            hooks: new \Acme\Loyalty\Hooks\LoyaltyHooks,
            admin: new AdminUI(menu: [
                new MenuItem(label: 'Loyalty', route: 'acme.loyalty.dashboard', icon: 'star'),
            ]),
            // Optional: declarative settings form, auto-rendered by the host (see §12).
            settingsSchema: new SettingsSchema(fields: [
                new SettingField(name: 'points_per_currency', label: 'Points per currency unit', type: 'number', default: 1),
                new SettingField(name: 'redemption_url', label: 'Redemption URL', type: 'url'),
            ]),
        );
    }
}

Register your service provider in your package's composer.json under extra.laravel.providers so the platform boots it. Optionally implement Installable (onInstall(InstallContext) / onUninstall(InstallContext)) for lifecycle hooks.

Step 3 — Test with AppTester

use Flashmandu\AppSdk\Testing\AppTester;
use Acme\Loyalty\Manifest;

it('awards points when an order is paid', function (): void {
    $tester = AppTester::for(Manifest::class);

    // install() is required — listeners are a no-op until then.
    $tester->install()->emit('order.status.changed', ['order_id' => 42, 'status' => 'paid']);

    expect($tester->wasFired('order.status.changed'))->toBeTrue();
    expect($tester->fired('order.status.changed'))->toBe(1);
});

No HTTP, no Octane, no fixtures — just your manifest and assertions. Use it in your own Pest or PHPUnit suite.

Next — the rest of the PHP track. A manifest alone does not ship an app. Continue with 3a · package anatomy & registration (service provider, routes, your own tables, and how the platform is told your app exists), 3b · sidebar nav & admin screens, 3c · hooks in PHP, 3d · platform ports for reading and writing host data, and 3e · install, upgrade & settings.

PHP-native — in-process

3a · Package anatomy & registration

composer require flashmandu/app-sdk gives you contracts and nothing else — the SDK has no Laravel dependency and boots nothing. What turns those contracts into a working app is a small Composer package with a service provider, plus one registration step on the platform. This section is everything between installed the SDK and the app shows up in a merchant's admin.

Who this track is for. A PHP-native app runs inside the platform process with the host's full privileges — database, filesystem, secrets. The platform therefore only runs PHP apps whose code the operator has deliberately placed on the server (a first-party package, or a partner package the operator has vendored and reviewed). If you are a third-party developer shipping to merchants on someone else's platform, you want Track B — remote apps: same hooks, same scopes, same sidebar, your code on your own server.

a. What the package looks like

packages/acme/loyalty/
├── composer.json                     # extra.laravel.providers → your service provider
├── src/
│   ├── Manifest.php                  # implements AppProvider (+ Installable, Upgradable)
│   ├── LoyaltyServiceProvider.php    # boots routes, views, migrations, Livewire
│   ├── Hooks/LoyaltyHooks.php        # implements AppHooks              → §3c
│   ├── Livewire/Dashboard.php        # your admin screens               → §3b
│   ├── Models/LoyaltyPoint.php       # your OWN tables (profile-scoped) → §3a.d
│   └── Models/Concerns/BelongsToAppProfile.php
├── routes/web.php                    # your named routes                → §3b
├── database/migrations/
├── resources/views/
└── tests/

b. composer.json

{
    "name": "acme/loyalty",
    "type": "library",
    "require": {
        "php": "^8.2",
        "flashmandu/app-sdk": "^1.0"
    },
    "autoload": { "psr-4": { "Acme\\Loyalty\\": "src/" } },
    "extra": {
        "laravel": {
            "providers": ["Acme\\Loyalty\\LoyaltyServiceProvider"]
        }
    }
}

Do not re-declare the host's own packages (UI components, Flux, Livewire) as dependencies. The host guarantees them; declaring them again invites a version fight at composer update time.

c. The service provider — the only wiring you write

<?php

namespace Acme\Loyalty;

use Acme\Loyalty\Livewire\Dashboard;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;

class LoyaltyServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
        $this->loadViewsFrom(__DIR__.'/../resources/views', 'loyalty');
        $this->loadRoutesFrom(__DIR__.'/../routes/web.php');

        // Full-page Livewire components must be registered by name.
        Livewire::component('loyalty.dashboard', Dashboard::class);
    }
}

Your routes carry the installed-app middleware. That is what enters your app's identity for the request, so the platform ports (§3d) can check your granted scopes. Without it your own screens reach the ports as the host, and "not installed" would silently be the most privileged state a request can be in — so the middleware refuses a profile where your app is missing or disabled.

// routes/web.php
use Acme\Loyalty\Livewire;
use Illuminate\Support\Facades\Route;

Route::middleware(['web', 'auth', 'installed-app:acme/loyalty'])
    ->prefix('apps/loyalty')
    ->name('loyalty.')        // → route names 'loyalty.dashboard', 'loyalty.members.index'
    ->group(function (): void {
        Route::get('/', Livewire\Dashboard::class)->name('dashboard');
        Route::get('/members', Livewire\Members\Index::class)->name('members.index');
    });

d. Your tables are yours — but they must be tenant-scoped

Store platform references (an order id, a location id) as plain scalar columns, never as foreign keys into host tables: a deleted host row must not cascade into your data, and you must not depend on core model internals. Scope every table by profile_id, resolved from ProfileScope — the same resolver the ports use, so your app can only ever read and write the profile the request is already scoped to.

use Flashmandu\Apps\Ports\ProfileScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

trait BelongsToAppProfile
{
    protected static function bootBelongsToAppProfile(): void
    {
        static::addGlobalScope('app_profile', function (Builder $builder): void {
            if (($profileId = app(ProfileScope::class)->profileId()) !== null) {
                $builder->where($builder->getModel()->getTable().'.profile_id', $profileId);
            }
        });

        static::creating(function (Model $model): void {
            $model->profile_id ??= app(ProfileScope::class)->profileId();
        });
    }
}

Prefix your tables (loyalty_*) so they can never collide with the host's or another app's schema.

e. Registering the app with the platform

The registry answers one question — does this provider class exist? — and there are two ways to answer it. Either way, a merchant then installs the app from their Apps page, which is what creates the per-profile installed_apps row that gates hooks, menu items and scopes.

Path How Use when
Config (first-party) Add Acme\Loyalty\Manifest::class to config/apps.php → registered. You own the platform deploy. Trusted code only — it executes in-process with full host access.
Self-service (custom app) A super-admin opens Apps → Add custom app and enters an app id, a display name and the provider FQCN. Stored as a custom_apps row. The code is already autoloadable on the server and you want it in the marketplace without a config edit or deploy.

The self-service form validates both conditions before saving: the class must be autoloadable ("place the package in packages/ or install it via Composer first") and it must implement Flashmandu\AppSdk\AppProvider.

Octane note. The app registry is a singleton: manifests are built once and reused across requests. Writes to installed_apps / custom_apps forget it automatically, so installs and enables take effect immediately. Editing your manifest PHP does not — reload the workers (php artisan octane:reload) after changing menu items, scopes, extensions or the settings schema, or you will keep seeing the old manifest.

PHP-native — in-process

3b · Sidebar nav & admin screens

A PHP app's screens are ordinary full-page Livewire components rendered in the host layout, so they must be visually indistinguishable from first-party screens. Nothing here is decoration: merchants read your rows with the same eyes they read their orders list.

a. Sidebar entries

Declare them on the manifest's AdminUI. Unlike the remote track (which lists nav_items paths in flashmandu.app.toml, §2d), a PHP app points at named routes.

use Flashmandu\AppSdk\Admin\AdminUI;
use Flashmandu\AppSdk\Admin\MenuItem;
use Flashmandu\AppSdk\Scope;

admin: new AdminUI(menu: [
    new MenuItem(
        label: 'Loyalty',
        route: 'loyalty.dashboard',        // a REGISTERED named route
        icon: 'star',                      // host icon name (heroicon set)
        requiredScopes: [Scope::ReadCustomers], // hidden unless granted
        children: [                        // indented sidebar sub-links, in this order
            new MenuItem(label: 'Members', route: 'loyalty.members.index', icon: 'users'),
            new MenuItem(label: 'Rewards', route: 'loyalty.rewards.index', icon: 'gift'),
            new MenuItem(label: 'Settings', route: 'loyalty.settings', icon: 'cog-6-tooth', children: [
                new MenuItem(label: 'Tiers', route: 'loyalty.settings.tiers'),
                new MenuItem(label: 'Earning rules', route: 'loyalty.settings.rules'),
            ]),
        ],
    ),
]),
  • !The route must exist. The host renders an item only when Route::has($item->route) is true. A typo, or a route group that did not load, means the item is silently skipped — this is the single most common "my app has no sidebar entry" cause. Verify with php artisan route:list --name=loyalty.
  • !The app must be installed and enabled for the current profile. A registered-but-not-installed app contributes nothing.
  • ·requiredScopes gates per item, not per app: an admin-only screen can be hidden while the app's main entry stays visible.
  • ·icon is a host icon name (star, building-office-2, puzzle-piece…). An unknown name renders nothing rather than failing.
  • ·children is your sub-navigation, and it is where it belongs: the host renders each child as an indented sidebar link under your app's entry, in declaration order — the same shape a remote app gets from [[nav_items]]. Do not put a tab strip in the page. A child may itself declare children: the host renders it as a collapsible sub-group, indented one step further — two levels below your app's row, which is the limit (a grandchild's own children are ignored). Every level is gated by its own requiredScopes and skipped when its route is not registered, so a typo costs one link rather than the sidebar. A sub-group whose own route is missing still renders, carrying its surviving children.

b. The page shell — get this exactly right

Render through the host layout and pass a title (it becomes the browser tab title). The host's flux:main already applies page padding, so your view root is a plain <div> — never wrap a page in p-6, max-w-* or a container, and never register your own layout.

public function render(): View
{
    return view('loyalty::members.index')
        ->layout('components.layouts.app', ['title' => __('Loyalty members')]);
}

c. Header, filters, table, row actions

This is the whole index-screen vocabulary. Hand-rolled toolbars and flux:table are not used anywhere in the host — copy this instead of approximating it.

<div>
    {{-- Header: title + subtitle + ONE primary action, right-aligned --}}
    <div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center">
        <div>
            <flux:heading size="xl">{{ __('Members') }}</flux:heading>
            <flux:text variant="subtitle">{{ __('Everyone earning points on this storefront.') }}</flux:text>
        </div>
        <flux:button variant="primary" icon="plus" class="lg:ml-auto" wire:click="create">
            {{ __('Add member') }}
        </flux:button>
    </div>

    <x-alerts.flash-messages />

    {{-- Search + filters: mandatory on list screens, never a hand-rolled toolbar --}}
    <x-ui::filter-bar :state="$this->filterState()" :profile-id="$profileId ?? 0" scope="loyalty-members" />

    {{-- The plain-table card --}}
    <div class="overflow-hidden md:rounded-lg md:border md:border-gray-200 md:bg-white md:shadow-sm dark:md:border-gray-700 dark:md:bg-gray-800">
        <div class="overflow-x-auto">
            <table class="w-full">
                <thead class="bg-gray-50 dark:bg-gray-700">
                    <tr>
                        <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-300">Name</th>
                        <th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500 dark:text-gray-300">Actions</th>
                    </tr>
                </thead>
                <tbody class="divide-y divide-gray-200 dark:divide-gray-600">
                    <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50" wire:key="member-{id}">
                        <td class="px-4 py-3 font-medium text-gray-900 dark:text-white">…</td>
                        <td class="px-4 py-3">
                            <div class="flex items-center justify-end gap-2">
                                <flux:tooltip content="Edit" position="top">
                                    <x-ui::icon-button icon="pencil" color="blue" wire:click="edit(1)" />
                                </flux:tooltip>
                                <flux:tooltip content="Delete" position="top">
                                    <x-ui::icon-button icon="trash" color="red" wire:click="delete(1)"
                                        wire:confirm="Remove this member?" />
                                </flux:tooltip>
                            </div>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>
    <div class="mt-6">{{ $members->links() }}</div>
</div>

The filter bar is backed by a small contract on the component: use ProvidesFilterSchema, WithPagination;, a filterState(): array method returning your fields, and clearFilters(): void. Each field's key is the public property your query already reads, so no query logic changes. scope must be unique per screen — it keys the merchant's pinned filters in localStorage.

public function filterState(): array
{
    return [
        'search' => ['type' => 'search', 'key' => 'search', 'label' => 'Search members…', 'value' => $this->search],
        'tier'   => ['type' => 'select', 'key' => 'tierFilter', 'label' => 'Tier', 'value' => $this->tierFilter,
                     'options' => [['value' => '', 'label' => 'All'], ['value' => 'gold', 'label' => 'Gold']]],
    ];
}

Row-action colour semantics

blue = edit/manage · red = delete/cancel · green = confirm/payment/duplicate · purple = print/history · teal = external link · amber = star/warn. Explicit tinted icon buttons first; a dropdown only for genuine overflow, and then ellipsis-vertical.

Every wait needs a visible state

Any click that hits the server gets feedback: wire:loading on the button, wire:target for the specific action, and wire:navigate on links. A dead-feeling click reads as a broken app. The filter bar handles its own pending state — do not add "Loading…" next to it.

Empty states and badges. An empty table body gets a single centred cell (px-4 py-12 text-center text-gray-500) saying what would go there and offering the primary action — never a blank card. Status is <flux:badge size="sm" color="green|amber|red|zinc">, not coloured text. Subtitles are <flux:text variant="subtitle">, never <flux:subheading>.

PHP-native — in-process

3c · Hooks in PHP

Hooks are how your app reacts to the merchant's business without patching the host. You declare them once on an AppHooks class; the platform collects the declarations at boot and decides at fire time which of them run — so a listener only ever executes for a profile that installed and enabled your app. The full live catalog of event and filter names is in §9.

a. Declaring listeners and filters

<?php

namespace Acme\Loyalty\Hooks;

use Flashmandu\AppSdk\AppContext;
use Flashmandu\AppSdk\AppHooks;
use Flashmandu\AppSdk\HookRegistrar;
use Flashmandu\AppSdk\Hooks\HookName;
use Flashmandu\AppSdk\Scope;
use Throwable;

class LoyaltyHooks implements AppHooks
{
    public function register(HookRegistrar $registrar): void
    {
        // EVENT — fire-and-forget notification. Return value is ignored.
        $registrar->on(HookName::OrderStatusChanged->value, function (array $payload, AppContext $ctx): void {
            if (($payload['status'] ?? null) !== 'paid' || ! $ctx->hasScope(Scope::ReadOrders)) {
                return;
            }

            try {
                AwardPoints::for($ctx->profileId(), (int) $payload['order_id']);
            } catch (Throwable $e) {
                report($e); // see (c) — the host does NOT catch this for you
            }
        });

        // FILTER — you receive a value and MUST return one.
        $registrar->filter('cart.totals', function (array $totals, AppContext $ctx): array {
            $totals['grand_total'] -= $this->redemptionFor($ctx->profileId());

            return $totals;
        });
    }
}

Attach the class to your manifest with hooks: new LoyaltyHooks. Registration happens once per worker, so the constructor must stay cheap — no DB, no HTTP. Resolve services inside the closure.

b. What you get at fire time

  • ·$payload — a plain array<string, mixed>, identical to the JSON body a remote app receives by webhook. Treat every key as optional and coerce your own types.
  • ·$ctx->profileId() — the merchant this fire belongs to. Can be null (console/queue context with no tenant): return early, do not guess.
  • ·$ctx->hasScope(Scope::X) — the scopes this merchant actually granted, which may be fewer than your manifest requested.
  • ·Ordering across apps is unspecified. Never write a filter that assumes it runs first or last.
c. Your exceptions are not caught. Listeners run inline, inside the host request that fired the event. An uncaught throw from your listener fails the merchant's checkout, not just your feature. Wrap every listener body in try/catch (Throwable), report() it, and return. Anything slow (HTTP calls, PDF generation, mail) belongs on a queued job that the listener merely dispatches.

d. Emitting and filtering from your own code

use Flashmandu\Apps\Hooks;

// Fires in-process listeners of every enabled app for the CURRENT profile,
// and delivers the same event to enabled remote apps as a signed webhook.
app(Hooks::class)->emit('loyalty.points.awarded', ['party_id' => 12, 'points' => 50]);

// Pipe a value through every enabled app's mutators (in-process only).
$totals = app(Hooks::class)->filter('cart.totals', $totals);

With no profile in scope, emit() is a no-op and filter() returns the value untouched — that is the tenancy guarantee, not a bug to work around.

e. Test the loop without the platform

$tester = AppTester::for(Acme\Loyalty\Manifest::class);

// install() is required — nothing fires before it, exactly like the real engine.
$tester->install()->emit('order.status.changed', ['order_id' => 42, 'status' => 'paid']);

expect($tester->wasFired('order.status.changed'))->toBeTrue();
expect($tester->runFilter('cart.totals', ['grand_total' => 100]))->toBe(['grand_total' => 90]);

PHP-native — in-process

3d · Platform ports — reading & writing host data

An in-process app could query the host's Eloquent models directly. It must not. Ports are the supported boundary: they return readonly value objects instead of models, so your app never depends on core internals (casts, relations, cent-vs-rupee accessors) and a host refactor cannot break you. Every port method is scope-checked on every call, so access is enforced rather than advisory.

Port Methods Scope
CatalogPortforIds(), search()read:catalog
PartyPortforIds(), search(), ensureParties()read:customers / write:customers
LocationPortforIds(), ofKind(), ensureLocations()read:locations / write:locations
OrdersPortforLocationRefs()read:orders
LedgerPortpost(), reverse(), postingsForReferences()read:ledger / write:ledger
DiscountPortforIds(), forCodes(), quote(), evaluate(), rulesForSource(), upsertRule(), deleteRule()read:discounts · write:discounts
PricingPortquoteLines()read:pricing
TaxPortquote(), quoteLines(), taxes()read:tax
SequencePortreserve(), reserveBlock()write:sequences
PaymentPortbegin(), forReferences(), refund(), recordTenders(), startGatewayPayment(), refundTender(), chargeStoredInstrument(), gateways()read:payments / write:payments
MessagingPortsendTemplatedEmail(), sendTemplatedWhatsapp()send:messages
ModuleContentPortentries()read:content
SettingsPortcurrent(), forIds()read:settings
NotificationPortensureType(), notifyStaff(), notifyUser()send:notifications
BroadcastPorttoProfile()broadcast:events

a. Using them

use Flashmandu\Apps\Ports\LocationPort;
use Flashmandu\Apps\Ports\NotificationPort;

class Index extends Component
{
    public function render(LocationPort $locations): View
    {
        $members = LoyaltyMember::query()->paginate();

        // ONE call for the whole page — resolve every reference at once.
        $names = $locations->forIds($members->pluck('location_ref')->filter()->all());

        return view('loyalty::members.index', compact('members', 'names'))
            ->layout('components.layouts.app', ['title' => __('Members')]);
    }
}

app(NotificationPort::class)->notifyStaff('loyalty.tier_reached', 'Gold tier reached', 'Asha just hit Gold.');

b. The four rules

  • 1Batch-first. Every read is forIds(array)-shaped and returns a collection keyed by the id you asked for. There is deliberately no find($id) anywhere in the namespace — with no per-row accessor to reach for, an N+1 across the plugin boundary is not something you can write by accident.
  • 2No Eloquent crosses the boundary. You get readonly DTOs. Store platform ids as scalars in your own tables; a deleted host row can then never cascade into your data.
  • 3Degrade, don't crash. isAvailable() is false when the host exposes no such resource (or no profile is in scope). Hide the tab instead of throwing.
  • 4Denials throw and are audited. A call without the scope raises ScopeDeniedException and writes an audit row the merchant can see. Check $ctx->hasScope() first on optional features rather than catching it.
Port scopes cannot be requested in a manifest — yet. The manifest's Scope enum ships from the vendor SDK, so the port scopes (read:catalog, write:ledger, send:notifications, …) live platform-side instead. A first-party app names the ones it needs under config/apps.php → default_port_scopes, and they are granted alongside the manifest scopes at install. See §10 for both lists.
// config/apps.php
'default_port_scopes' => [
    'acme/loyalty' => ['read:catalog', 'read:settings', 'send:notifications'],
],
Identity comes from the route, never the request. Port calls are checked against whichever app is currently executing, and that is entered by the installed-app:<appId> middleware on your route group (§3a.c) — or, for a remote app, by its authenticated API token. Inside a queued job you dispatch, no app is in scope and calls run as the host, so re-enter it explicitly if the job is doing work on the app's behalf. Tenancy is resolved the same way: ProfileScope reads the request's profile; a job or console command pins one with ProfileScope::forProfileId($id).

PHP-native — in-process

3e · Install, upgrade & settings

Registration makes your app installable; installation is per merchant. These three optional interfaces are the whole lifecycle surface.

use Flashmandu\Apps\Contracts\Upgradable;   // platform-side (not yet in the SDK)
use Flashmandu\AppSdk\Installable;
use Flashmandu\AppSdk\InstallContext;

class Manifest implements AppProvider, Installable, Upgradable
{
    public function onInstall(InstallContext $ctx): void
    {
        // Seed defaults, register notification types, provision app-local rows.
        // Idempotent: it may run again after an uninstall/reinstall.
        app(NotificationPort::class)->ensureType('loyalty.tier_reached', 'Loyalty tier reached');
    }

    public function onUpgrade(InstallContext $ctx, string $from, string $to): void
    {
        // Fires once per version change, BEFORE the new version is recorded,
        // and never on first install.
    }

    public function onUninstall(InstallContext $ctx): void
    {
        // Prefer leaving business records intact. An accidental uninstall must
        // not be the thing that destroys a merchant's history; purging is a
        // separate, explicit operation.
    }
}

Bump version in the manifest to trigger onUpgrade for every profile still on the old one. Migrations loaded by your service provider run with the host's php artisan migrate — they are global, so never write a migration that assumes a single tenant.

For merchant-editable configuration, declare a settingsSchema and the host renders the form for you at /apps/<appId>/settings, persisting values on the install row — see §12 for the field types, and read them back with app(AppManager::class)->settingsFor($profile, 'acme/loyalty'). Build a settings screen of your own only when the declarative form genuinely cannot express it.

Embed layer

4 · App Bridge SDK

Remote apps render inside the merchant admin via a sandboxed iframe at https://business-dev.autohisab.com/apps/<appId>/embed. The @flashmandu/app-bridge SDK wraps the postMessage protocol so your iframe can read context, fire toasts, navigate the admin, and call the API without ever holding the long-lived api_token in the browser.

Install

npm install @flashmandu/app-bridge

Ready → context

createAppBridge().ready() resolves once the host has acknowledged the iframe and returned context: the host origin, the profile you are embedded in, the scopes the app was granted, and the surfacing target (full-page vs an extension slot).

import { createAppBridge } from '@flashmandu/app-bridge';

const bridge = createAppBridge();

bridge.ready().then(({ profile, scopes, target, origin }) => {
  console.log('embedded in profile', profile, 'as', target, 'with', scopes);
});

Proxied API calls (the allowlist)

bridge.request({ '{' } method, path, body { '}' } }) posts a REQUEST to the host, which POSTs the same payload server-side to /api/apps/proxy behind the merchant's session. Scope enforcement runs on the host before any data leaves the boundary. Only the read paths below are proxied — writes go through GraphQL (section 8).

MethodPathScopeReturns
GET me — App identity, profile_id, and granted scopes.
GET orders read:orders POS orders for given location_ids, keyed by location, newest first.
GET catalog/items read:catalog Catalog items by ids, or a search term (limit ≤ 100).
GET parties read:customers Parties (customers) by ids, or a search term.
GET locations read:locations Service locations by ids, or filtered by kind.
// Search the catalog for "coffee" — runs as the merchant, not the app's bearer.
const res = await bridge.request({
  method: 'GET',
  path:    'catalog/items',
  body:    { term: 'coffee', limit: 25 },
});
// res.body.items === [<CatalogItem>, ...]

Toast, navigate

bridge.toast({ message: 'Saved!', variant: 'success' });   // variant: default | success | error
bridge.navigate({ href: 'https://business-dev.autohisab.com/orders/42' });     // absolute URL inside the admin

Required — user feedback on every write

Every create, update, delete and reorder in your app must report its outcome. The merchant is looking at your iframe inside their admin, where every other screen confirms a save — an action that silently succeeds is indistinguishable from one that silently failed. Use useToast() (React) or bridge.toast(); both post FLASHMANDU_BRIDGE:TOAST, which the host feeds into the same toast pipeline its own screens use — so your confirmation looks exactly like the one a merchant gets from creating an item.

const toast = useToast();

const save = async () => {
  let res;
  try {
    res = await fetch(url, { method: 'POST', body });
  } catch {
    // Don't forget this branch: an unhandled rejection means the merchant
    // clicks Save and NOTHING happens — no error, no spinner, no toast.
    toast('Save failed: the request could not be completed.', 'error');
    return;
  }
  if (!res.ok) {
    const { error } = await res.json().catch(() => ({}));
    toast('Save failed: ' + (error ?? res.status), 'error');
    return;
  }
  toast('Item saved', 'success');   // say WHAT succeeded, not "Done"
};

Two rules learned the hard way: give each action its own message (a generic "Updated" after a drag-and-drop tells the merchant nothing about whether the row actually moved), and never fire a success toast for a no-op — if a drag ends where it started, skip the request and the toast.

Never use confirm(), alert() or prompt()

The embed shell frames your app with a sandbox attribute that deliberately omits allow-modals. Browsers block native dialogs in such a frame — and a blocked confirm() does not throw, it returns false. The extremely common shape below therefore returns early every single time, so the delete never runs, with no error and no toast to explain it:

// BROKEN inside the embed — silently does nothing, forever.
if (!confirm('Delete this?')) return;

Render your own dialog from the UI kit's Modal instead. It also looks like the admin, which a native browser dialog never will.

The UI kit ships useConfirm() for exactly this — a promise-based dialog that reads like confirm() at the call site and renders as an admin modal.

import { useConfirm } from '@flashmandu/app-bridge-ui/react';

const { confirm, dialog } = useConfirm();

const remove = async () => {
  if (!(await confirm({ title: 'Delete “Q3 report”?', confirmLabel: 'Delete' }))) return;
  // ... perform the delete, then toast the outcome
};

// `dialog` must be rendered somewhere in this component, or the promise never settles.
return <>{dialog}{/* … */}</>;

Your app's navigation

Declare your sections as [[nav_items]] in flashmandu.app.toml. The platform persists them on install and renders them in the main admin sidebar, as sub-items under your app's entry — the same place every other module's pages live, with active-state resolved from ?path=. Nothing to wire at runtime.

[[nav_items]]
label = "Maps"
path  = "/maps"
icon  = "map"      # Heroicon name — see below
order = 2
The icon namespace is Heroicons, not Tabler. The sidebar renders each item with <flux:navlist.item :icon="…">, which resolves <flux:icon.{name}> — Flux ships the Heroicons set (318 names), and this platform adds 4 of its own, for 322 resolvable names. Use the kebab-case Heroicon name with no prefix: map, calendar-days, calculator, squares-2x2, users. A Tabler name that has no Heroicon twin — calendar-event is the classic one — renders no icon at all, silently. That is deliberate, not a bug: an unknown name would otherwise throw "Unable to locate a class or view for component" from a partial that sits in the sidebar of every admin page, so one bad icon in one app would 500 the merchant's whole admin. The resolver checks the name against the registered Flux component path and falls back to no icon (apps::nav-items). If your icon is missing, the name is the thing to check first.

The SDK also exposes bridge.setSidebarLinks(), but the host does not render it: your sections already appear in the main sidebar from the manifest, and painting them a second time beside the iframe just duplicates the list on screen. Use [[nav_items]].

Protocol — FLASHMANDU_BRIDGE:* postMessage types

These are the typed messages the host (app-bridge-host.ts) routes today. The host fail-closes on origin: it only accepts messages from the origin the embed shell stamped for this app (window.__APP_BRIDGE_APP_ORIGIN__ / data-app-origin), and refuses to wire up at all if no origin was published. Configure your app's embed_origin in production (config/apps.php) — leaving it unset makes the bridge accept any origin, which is unsafe.

TypeDirectionPayloadDescription
FLASHMANDU_BRIDGE:READY app → host — App announces it is loaded; host replies with CONTEXT and retires the loading overlay.
FLASHMANDU_BRIDGE:CONTEXT host → app { origin, channel } Host publishes its origin + per-iframe channel id to the app on ready.
FLASHMANDU_BRIDGE:TOAST app → host { message, variant } Show a toast in the host admin UI — the same toast the platform's own screens use. variant: default|success|error|warning|info.
FLASHMANDU_BRIDGE:NAVIGATE app → host { href } Ask the host to navigate the admin to a same-origin URL.
FLASHMANDU_BRIDGE:REQUEST app → host { id, method, path, body? } Proxied API call (app never holds its api_token in the browser).
FLASHMANDU_BRIDGE:RESPONSE host → app { id, status, body } Resolved response paired to a REQUEST by id.
FLASHMANDU_BRIDGE:RESIZE app → host { height } Request an iframe height. Clamped to 50–2000px and throttled to one write per frame.
FLASHMANDU_BRIDGE:ERROR app → host { message } Report an in-app error for health monitoring. Console-only on the host.
FLASHMANDU_BRIDGE:PATH_CHANGE app → host { path, query } Mirror the in-app route into the host address bar (replaceState). Host-owned params are preserved.
FLASHMANDU_BRIDGE:SET_TITLE app → host { title } bridge.setTitle() / useBridgeTitle() — name the current page; the host keeps its own suffix on the browser tab.
FLASHMANDU_BRIDGE:SET_SIDEBAR_LINKS app → host { links: [{ label, path, icon? }] } bridge.setSidebarLinks() — declare the app's sidebar entries at runtime. The host renders them under the app and persists them, so they are right on every admin page. No CLI re-registration; replaces static [[nav_items]].
FLASHMANDU_BRIDGE:SET_CAPABILITIES app → host { capabilities: { hostNavigate: boolean } } Idempotent state, not an event to count. Sent when the first onHostNavigate listener registers, again as false when the last is torn down, and restated once on CONTEXT so a late-wiring host cannot miss it. Until it is true the host suppresses nothing.
FLASHMANDU_BRIDGE:HOST_NAVIGATE host → app { id, path } The merchant clicked one of those entries and the host has already suppressed its own navigation — route internally. id is host-allocated and unrelated to the REQUEST/RESPONSE counter.
FLASHMANDU_BRIDGE:SET_SEARCH_PROVIDER app → host { domains: [{ type, label }], commands: [{ id, label, subtitle?, path, icon?, keywords? }], endpoint?: string|null } bridge.setSearchProvider() — declare what your app contributes to the merchant's global (⌘K) search. Persisted, so your entries appear on every admin page, not only inside your iframe. Commands are matched in-process and cost nothing; `endpoint` is POSTed live (signed, 250ms budget) and needs the receive:search scope. A `domains[].type` naming a record the platform already searches — item, order, customer, outlet… — is REFUSED with a 422: your synced copy of platform data must not appear beside the platform's own row. To enrich a platform record instead, return a result carrying platformRef: { type, id }, which the host renders under that record.
FLASHMANDU_BRIDGE:HOST_NAVIGATE_ACK app → host { id, path } Echoes the id back. Sent only when the message passed the origin gate AND a listener actually received it. No ack means the host falls back to a real navigation.

REQUEST/RESPONSE are paired by a client-chosen id. A network failure is reported back with status: 0 so the SDK can distinguish transport failures from server errors.

Remote app

4a · Modals & resource pickers

A modal rendered inside your iframe cannot cover the admin. It is trapped in your frame, so it looks like a dialog inside a box. Ask the host to render it instead and it covers the whole screen, exactly like a native confirm.

Message modal (declarative)

const { confirmed } = await bridge.modal.message({
  title: 'Delete this map?',
  body: 'Its 42 areas will be removed. This cannot be undone.',
  confirmLabel: 'Delete map',
  cancelLabel: 'Keep it',
});

if (confirmed) { await deleteMap(); }

The host renders it with the platform's own dialog components. Caps: title 80 chars, body 500, button labels 32 — text only, never HTML. Use this for every destructive confirm.

Route modal (your own UI, full-screen)

await bridge.modal.route('/maps/42/compose');

Opens a full-viewport overlay containing a second sandboxed frame of your app at that path, with ?variant=modal appended. It gets its own bridge session. One modal at a time — a second open request while one is up is ignored.

On phones and tablets (viewports below 1024px) the host renders both kinds as the platform's bottom drawer instead of a centred dialog or a full-screen overlay: a grab handle, your title, a close button, and your route frame in its body. Swiping it down, tapping outside it, pressing Escape or the close button all resolve exactly like Cancel (confirmed: false). Placement is the host's choice: there is no option to set and nothing to change in your code.

Resource picker

const { cancelled, selections } = await bridge.picker({
  kind: 'item',        // 'item' | 'media'
  multiple: true,
});

// selections: [{ id, label, thumbnail?, meta? }]

This opens the merchant's own product search or media library — the UI they already know — rather than one you rebuild. You get summaries back, not full records; fetch details through the GraphQL API with your scopes. Picker kinds are scope-gated (item ⇒ read:catalog, media ⇒ read:media); without the scope you get { cancelled: true, error: 'scope' } immediately.

Embed layer

5 · App Bridge UI Kit

@flashmandu/app-bridge-ui is a small, framework-agnostic CSS kit that makes your embedded iframe look native to the admin. It ships design tokens and a handful of component classes — no React/Vue dependency, works in plain HTML.

Install + tokens

npm install @flashmandu/app-bridge-ui
/* your app.css */
@import "@flashmandu/app-bridge-ui/tokens.css";
/* Optionally: @import "@flashmandu/app-bridge-ui/components.css"; */

Importing tokens.css exposes a set of --fm-* custom properties on :root — colors, typography, spacing, radius, shadows — that mirror the host admin's design system. Build your own components on top, or use the kit's component classes below.

Component classes

.fm-btn

Buttons. Modifier classes for .fm-btn--primary, .fm-btn--ghost, .fm-btn--danger.

.fm-card

Surface container with token-driven border, radius, shadow.

.fm-input

Text inputs, selects, textareas. Pairs with .fm-label.

.fm-callout

Info/success/warning/error banners. Variant via data-fm-variant.

.fm-table

Bordered data table; honors --fm-* tokens for stripes/hover.

.fm-badge

Inline status pill.

Dark mode

Dark mode is automatic — the tokens flip on prefers-color-scheme: dark, matching the admin in whichever OS theme the merchant runs. Override explicitly by setting data-fm-theme="dark" (or "light") on any ancestor, including <html>.

<html data-fm-theme="dark">
  <body class="fm-bg fm-text">
    <div class="fm-card">
      <button class="fm-btn fm-btn--primary">Save</button>
    </div>
  </body>
</html>
See it running in the Showcase app: Kit gallery /kit

Remote app

6 · Webhooks

Webhooks are how a remote app hears about business events. Every delivery is HMAC-signed and timestamped; verify both before you trust a payload, and treat a delivery id as the key for your own idempotency.

For each event in your manifest's subscribed_events, the platform POSTs a JSON body to your webhook_url. Each delivery is signed with HMAC-SHA256 over "{timestamp}.{body}" keyed by your webhook_secret. The scaffolded template already implements the verifier below — this section explains the contract.

Request shape

POST /your-webhook-url HTTP/1.1
Host: your.app
Content-Type: application/json
X-App-Event: order.status.changed
X-App-Signature: <hex hmac-sha256>
X-App-Timestamp: 1760000000
X-App-Delivery: <uuid per attempt>

{"event":"order.status.changed","payload":{"order_id":42,"status":"paid"}}
No Authorization header on inbound webhooks. Authenticity is proved only by the HMAC signature. Do not look for a bearer token on inbound deliveries — the api_token is outbound-only (you use it to call the platform).

Signature formula

signature = hash_hmac('sha256', $timestamp . '.' . $body, $webhook_secret)

$body is the raw request body (do not re-serialize JSON). $timestamp is the X-App-Timestamp value as a string. Compare in constant time and reject deliveries whose timestamp is more than 5 minutes from now (replay window). X-App-Delivery is a per-attempt UUID — dedupe on it. Deliveries retry up to 3 times with backoff on non-2xx; up to 3 redirect hops are followed, each re-validated through the SSRF guard.

Verifier — PHP

$secret = getenv('APP_WEBHOOK_SECRET');
$body    = file_get_contents('php://input');
$ts      = $_SERVER['HTTP_X_APP_TIMESTAMP'] ?? '';
$sig     = $_SERVER['HTTP_X_APP_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $ts.'.'.$body, $secret);

if (! hash_equals($expected, (string) $sig)) {
    http_response_code(401);
    exit;
}

// 5-minute replay window.
if (abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}

$event   = $_SERVER['HTTP_X_APP_EVENT'] ?? '';
$payload = json_decode($body, true);

// ... dispatch $event with $payload['payload'].
http_response_code(200);

Verifier — Node.js / Express

import express from 'express';
import crypto from 'node:crypto';

const app = express();
// IMPORTANT: get the RAW body — verification must use exactly the bytes sent.
app.use(express.raw({ type: 'application/json' }));

app.post('/webhooks/flashmandu', (req, res) => {
  const secret   = process.env.APP_WEBHOOK_SECRET;
  const ts       = req.get('X-App-Timestamp') ?? '';
  const sig      = req.get('X-App-Signature') ?? '';
  const delivery = req.get('X-App-Delivery') ?? '';
  const event    = req.get('X-App-Event') ?? '';

  const body     = req.body.toString('utf8');
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${body}`)
    .digest('hex');

  if (
    !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)) ||
    Math.abs(Date.now() / 1000 - Number(ts)) > 300
  ) {
    return res.status(401).end();
  }

  const payload = JSON.parse(body).payload;
  // dispatch(event, payload, delivery)
  res.status(200).end();
});
HTTPS required. Webhook URLs must use https and must not resolve to private/loopback/metadata IPs. In the dev loop the cloudflared tunnel (section 2d) satisfies this automatically. Debug deliveries with autohisab-business logs.

Remote app

6a · Scheduled triggers

Your container has no cron. Declare schedules instead and the platform calls you — a signed webhook on your interval. This is how you do rollups, reconciliation sweeps, polling of an external API, or anything else periodic.

# flashmandu.app.toml

[[schedules]]
name  = "attendance-rollup"   # ^[a-z0-9][a-z0-9._-]{0,63}$, unique per app
every = "15m"                 # every OR cron — exactly one, never both

[[schedules]]
name     = "nightly-digest"
cron     = "0 18 * * *"       # 5-field cron
timezone = "Asia/Kathmandu"   # optional; defaults to the install's profile timezone

every takes <n><unit> with unit in s/m/h/d (and their long forms). Synced on autohisab-business dev, push and deploy. Limits: interval floor 5 minutes and ceiling 30 days, maximum 5 schedules per app. A cron whose next two fires land less than 5 minutes apart is rejected too — you cannot sneak past the floor.

The sync replaces rather than merges: a schedule you remove from the manifest is deleted. The manifest is the source of truth, and re-syncing is also how you re-enable a schedule the platform auto-disabled (see below) — a redeploy is read as "the developer fixed it".

What you receive

X-App-Event: schedule.tick

{"event":"schedule.tick","payload":{
   "schedule":"attendance-rollup",
   "profile_id":12,
   "scheduled_at":"2026-08-10T09:15:00Z"
}}

It is an ordinary webhook — same HMAC signature, same retries, same delivery log — so the verifier you already wrote handles it. Schedules are stored per install, so you get one tick per merchant with their profile_id.

Ticks do not queue up behind you. If your previous tick for that schedule has not been delivered yet, the next one is skipped rather than stacked. Firing times also carry a deterministic per-install offset, so a thousand installs of your app don't all hit you in the same second. Repeated dead-lettering auto-disables the schedule — check autohisab-business hooks deliveries if ticks stop arriving.
See it running in the Showcase app: Sync /sync

Remote app

6b · Delivery log & replay

Every delivery attempt is recorded and replayable. When a webhook doesn't arrive, you no longer have to guess whether the platform sent it or your endpoint dropped it.

autohisab-business hooks deliveries          # event, status, attempts, latency
autohisab-business hooks replay <delivery-id>

Retry schedule

A non-2xx response is retried with exponential backoff — 1m, 5m, 30m, 2h, 8h — so a delivery gets 6 attempts spanning roughly 10½ hours before it is dead-lettered and stops retrying. That window is deliberate: an endpoint that goes down overnight still receives its backlog the next morning. Each attempt is a separate row in the log with its own X-App-Delivery id, status code and latency. Records are kept for 30 days.

A replay is a new delivery, not a mutation of the old one — fresh delivery id, fresh signature, full retry ladder from attempt 1. The log is append-only, so the original failure stays visible after you fix and replay.

Deliveries are at-least-once: a timeout on your side may still have been processed, so dedupe on X-App-Delivery and treat handlers as idempotent. Payloads carry a per-resource sequence so you can safely drop an update that arrives out of order.

Respond fast, work later. The delivery timeout is 10 seconds. Acknowledge with 202 and continue processing asynchronously rather than doing the work inline — a slow handler burns your retry budget.
See it running in the Showcase app: Ops /ops

Remote app

7 · OAuth Install

OAuth is how a merchant grants your app scopes. The platform drives the flow with PKCE and hands you a long-lived api_token plus a webhook secret at the end — you never see the merchant's credentials.

Production installs use a Shopify-style OAuth flow with PKCE (S256). The merchant clicks install, the platform redirects them to your authorize_url with a code challenge; after consent they return to the platform callback; the platform then POSTs to your token_url to exchange the code and deliver your credentials.

Step 1 — Platform redirects to your authorize_url

GET https://your.app/oauth/authorize?
    client_id=flashmandu-platform
    &redirect_uri=https://business-dev.autohisab.com/apps/oauth/callback
    &response_type=code
    &state=<opaque 40-char token>
    &scope=read:orders read:customers
    &code_challenge=<base64url-sha256(code_verifier)>
    &code_challenge_method=S256

The platform pre-flights the install gate before the merchant ever lands on your consent screen, so they are never stranded on your page for a scope they cannot be granted. The state token is bound in cache for 10 minutes to (profile_id, app_id, user_id, code_verifier, scopes).

Step 2 — You redirect back with code + state

GET https://business-dev.autohisab.com/apps/oauth/callback?code=<your-code>&state=<same-state>

The platform validates state (CSRF, install binding, session binding) atomically with Cache::pull — a replay or double-click cannot succeed against the same state token.

Step 3 — Platform POSTs to your token_url (PKCE exchange + credential delivery)

POST https://your.app/oauth/token
Content-Type: application/json

{
  "grant_type": "authorization_code",
  "code": "<your-code>",
  "redirect_uri": "https://business-dev.autohisab.com/apps/oauth/callback",
  "client_id": "flashmandu-platform",
  "code_verifier": "<the verifier matching the challenge>"
}

Your token endpoint must respond 2xx to the exchange. Immediately after, the platform POSTs the signed credential payload (to token_url by default, or credential_delivery_url if you set one):

POST https://your.app/oauth/token   (or credential_delivery_url)
Content-Type: application/json

{
  "install_id": 123,
  "profile_id": 7,
  "app_id": "acme/loyalty",
  "api_token": "<plaintext bearer — store hashed, send outbound only>",
  "webhook_secret": "<use this to verify inbound webhooks>",
  "scopes": ["read:orders", "read:customers"],
  "subscribed_events": ["order.status.changed", "party.balance.changed"],
  "issued_at": "2026-07-28T12:00:00+00:00",
  "hmac": "<hex hmac-sha256 of the canonical JSON, keyed by webhook_secret>"
}
Verify the credential delivery. Recompute hash_hmac('sha256', $canonicalJson, $webhook_secret) over the canonical JSON of the payload (no escaped slashes, raw unicode) and compare with the hmac field. Then persist api_token, webhook_secret, scopes and subscribed events.
api_token + webhook_secret are delivered ONCE. They are shown at install and never again. If lost, the merchant rotates them from the platform's installed-apps screen (which issues a new token and a new webhook secret).

Remote app

7a · Connecting external accounts

Third-party credentials never touch the platform. When your app needs the merchant's Facebook page, Google Drive, Airtable base or TikTok shop, you run that OAuth flow yourself — the platform just gives you a reliable way to open the popup and hear back.

const { connected } = await bridge.connectExternal(
  'https://your.app/connect/facebook?profile=' + ctx.profileId,
  { timeoutMs: 120_000 },
);

How the flow runs

  1. connectExternal() opens a popup at your URL (the app frame is sandboxed with popups allowed).
  2. You redirect the merchant to Facebook/Google/etc. and they consent.
  3. The provider redirects back to your callback, on your origin.
  4. Your backend exchanges the code and stores the tokens in your own database (or, for app-level credentials, in platform secrets — see §16a).
  5. Your callback page posts completion to its opener; the promise resolves. A popup closed by the merchant rejects instead.
Never send third-party tokens to the platform. There is no endpoint for them and no reason to have one. The merchant's Meta page token is a secret between them and you; the platform's role ends at opening the window.

Media you hand to an external API

Providers like Meta and TikTok fetch images server-side, so give them the public CDN URL returned by the media API — never a presigned URL, which expires and will fail on their side, sometimes hours after your call appeared to succeed.

Remote app & first-party

8 · GraphQL API

The data API is a single schema served behind two HTTP endpoints, chosen by who is calling:

  • POST https://business-dev.autohisab.com/api/apps/graphql — the remote-app principal. Bearer-authenticated with your api_token, behind the same remote-app middleware as /me, so your app is resolved and its scope envelope entered before any resolver runs.
  • POST https://business-dev.autohisab.com/api/platform/graphql — the logged-in user principal. Session-authenticated for a first-party client (the admin console, an extension console) acting as the merchant, gated by capabilities instead of app scopes.

Every field on both endpoints resolves through a platform resource port wrapped by a Guarded decorator — the scope (or capability) check fires before any data leaves the boundary, so the same field is safe to expose to both principals from one schema. Some fields are gated by principal identity rather than a scope at all (an app reading its own hosted secrets; a user reading their own publisher org) — those are labelled accordingly in the scope column below, never left blank.

Read fields (Query) — 56 total

Grouped by domain, generated from the live SDL — see \Flashmandu\Apps\GraphQL\SchemaCatalog.

Accounts & Outlets

FieldScopeDescription
accounts(first: Int!, after: String) read:ledger A keyset page of the merchant's chart of accounts, ordered (updatedAt, id) ascending.
outlets(first: Int!, after: String) read:settings A keyset page of the merchant's outlets, ordered (updatedAt, id) ascending.

App Ops

FieldScopeDescription
appDeliveries(event: String, state: String, limit: Int, beforeId: Int) app principal only Recent webhook delivery attempts for the calling app, newest first.
appSchedules app principal only The calling install's declared schedules with their platform-side health.
appSecrets(appId: String!) app principal only Hosted-secret KEY NAMES for one platform-hosted app in the caller's publisher namespace.
hookCatalog — (ungated discovery) Every hook a developer can discover, with the scope it requires and how it is delivered.
platformCapabilities — (ungated discovery) Platform runtime capabilities: environment, tenant registry address, and whether platform hosting is available at all.

Bookings

FieldScopeDescription
bookingList(first: Int!, after: String, status: String, checkInFrom: String, checkInTo: String) read:bookings (app) · section.pms.bookings (user) One keyset page of bookings, ordered by `(updated_at, id)` so a walk stays consistent under concurrent writes at the front desk.
bookings(references: [String!]!) read:bookings Bookings for the given references, in one call.

CMS

FieldScopeDescription
cmsEntries(first: Int!, after: String, module: String, status: String, updatedAtMin: DateTime) read:content (app) · section.cms (user) One keyset page of CMS entry summaries, oldest-modified first.
cmsEntryBody(id: ID!) read:content (app) · section.cms (user) The long-form body of one published entry, as rendered HTML and as plain text.
moduleEntries(first: Int!, after: String, module: String, status: String, updatedAtMin: DateTime, expandRelations: Boolean) read:content (app) · section.cms (user) One keyset page of module entries with relation fields expanded.

Catalog

FieldScopeDescription
catalogItems(ids: [ID!]!) read:catalog Catalog items for the given ids.
catalogSearch(term: String!, limit: Int) read:catalog Active items matching a name/SKU term, bounded by `limit` (default 25 — omitting the argument lets CatalogPort::search's own default apply).
items(first: Int!, after: String, updatedAtMin: DateTime, query: String) read:catalog (app) · section.items (user) One keyset page of catalog items, oldest-modified first.

Customers

FieldScopeDescription
badgeType(typeSlug: String!) UNDOCUMENTED One badge-eligible customer type by slug, or null when no such type is visible to this profile or it may not be a badge — lets an app refuse loudly before a grant, since `grantPartyType` answers false both for "nothing to do" and "no such type".
customers(first: Int!, after: String, updatedAtMin: DateTime, query: String, partyTypeSlug: String) read:customers (app) · section.customers (user) One keyset page of the profile's customers, oldest `updatedAt` first.
parties(ids: [ID!]!) read:customers Parties (customers) for the given ids.
partySearch(term: String!, limit: Int) read:customers Active parties (customers) matching a name/phone/email term, bounded by `limit` so an app cannot pull a whole customer list through the boundary.
partyTypes read:customers The badge-eligible party types for this profile (active, customer-like, never an operational role such as supplier or employee, never a type the profile disabled), ordered by name — the vocabulary a membership tier names its badge from.

Discounts

FieldScopeDescription
discountEvaluate(request: DiscountEvaluateInput!) read:discounts ONE evaluation pass through the platform's discount engine — the same pass an in-process app gets from `DiscountPort::evaluate()`, with the same engine, the same caps, floors and stacking, and the same profile taken from scope rather than from an argument.
discountQuote(quotes: [DiscountQuoteInput!]!) read:discounts Discount amounts for a batch of (code, order total) pairs, computed by the platform's own calculator (Coupon::calculateDiscount) — validity windows, usage limits, minimum-order gates and caps included.
discountRules(source: String!) read:discounts The app's OWN rules of one source, highest priority first — the read half of the rule-writing surface, so an app can reconcile what it has already authored before upserting again.
discounts(ids: [ID!]!) read:discounts Coupons for the given ids.
discountsByCodes(codes: [String!]!) read:discounts Coupons for the given codes.

Employees

FieldScopeDescription
employees(first: Int!, after: String, updatedAtMin: DateTime) read:employees One keyset page of the merchant's employee directory, oldest-updated first.
employeesByIds(ids: [ID!]!) read:employees Employees for the given ids, for hydrating an `employee.*` webhook.

Extensions

FieldScopeDescription
installedAppExtensions user principal only (capability varies by surface) Every extension × target installed on the profile in scope, with its full four-gate chain and, when it is dark, the merchant-facing sentence naming the gate that failed.

Ledger

FieldScopeDescription
ledgerPostings(references: [String!]!) read:ledger Previously posted receipts for the given app references — the read half of the ledger surface, so an app can reconcile its own folio against the platform books in one query instead of re-posting to find out.

Locations

FieldScopeDescription
locations(ids: [ID!]!) read:locations Service locations for the given ids.
locationsOfKind(kind: String!, limit: Int) read:locations Active service locations of one kind (`table` / `room`), keyed order preserved, bounded by `limit`.

Media

FieldScopeDescription
media(first: Int!, after: String, updatedAtMin: DateTime, collection: String) read:media One keyset page of the merchant's media library, oldest-updated first.
mediaItems(ids: [ID!]!) read:media Media assets for the given ids.

Orders

FieldScopeDescription
orders(first: Int!, after: String, updatedAtMin: DateTime, dateMin: DateTime, dateMax: DateTime, status: String) read:orders (app) · section.orders (user) One keyset page of the profile's orders, oldest `updatedAt` first.
ordersByLocations(locationRefs: [ID!]!) read:orders POS orders for the given service-location refs, keyed by location id, each list newest first.

Other

FieldScopeDescription
anyCapabilityAllowed(capabilities: [String!]!) — (ungated identity) Whether the acting principal holds at least one of these capabilities on the profile in scope.
capabilityAllowed(capability: String!) — (ungated identity) Whether the acting principal holds this capability on the profile in scope.
currentTillSession(outletRef: Int) read:till_sessions (app) · section.till.view-all (user) The session the CURRENT VIEWER holds, or null.
openTillSessions(outletRefs: [Int!]!) read:till_sessions (app) · section.till.view-all (user) The open (or counting) sessions on the given outlets, in ONE call.
paymentGateways read:payments (app) · section.orders (user) The merchant's configured gateways, so an app can render a payment picker instead of guessing which gateways exist.
paymentsByReferences(references: [String!]!) read:payments (app) · section.orders (user) The app's own payments back, by the reference it supplied when it started them.
stockLevels(itemIds: [ID!]!, locationRef: Int) read:catalog On-hand levels for the given items, in one call.
tillMovements(sessionRef: String!, limit: Int) read:till_sessions (app) · section.till.view-all (user) One session's till ledger, newest first.
tillSession(sessionRef: String!) read:till_sessions (app) · section.till.view-all (user) One session by ref, or null when it is gone, belongs to another business, or sits at an outlet you cannot see.
tillSettlements(tillRef: String!, limit: Int) read:till_sessions (app) · section.till.view-all (user) Settlements banked out of a till, newest first, INCLUDING undone ones.

Pricing & Tax

FieldScopeDescription
merchantTaxes read:tax (app) · section.orders (user) The merchant's active taxes, so an app can offer a tax picker on a manual charge form instead of guessing ids.
pricingQuoteLines(lines: [PricingLineInput!]!, partyId: Int, context: String, date: String) read:pricing (app) · section.items (user) Price a batch of lines through the host pricing pipeline for the given party/context/date — the resolution order, lowest-replacement rule and price_source attribution all applied exactly as the till would apply them.
taxQuote(lines: [TaxLineInput!]!) read:tax (app) · section.orders (user) Quote tax on a batch of lines through the platform's own calculator — inclusive/exclusive handling and rounding order applied exactly as the printed invoice would apply them.
taxQuoteAmounts(lines: [TaxableAmountInput!]!) read:tax (app) · section.orders (user) Quote tax on a batch of BARE AMOUNTS rather than catalog items — a room night, a late-checkout fee, a folio adjustment: money the merchant charges that has no `items` row behind it.

Publisher

FieldScopeDescription
myPublisher user principal only The caller's own publisher org, or null when they belong to none — a first-run state, not a refusal.
myPublisherAppDeliveryHealth(appId: String!) user principal only Webhook delivery health for one owned app, aggregated across every merchant install.
myPublisherAppVersions(appId: String!) user principal only The submission/version review timeline for one owned app, newest first.
myPublisherApps user principal only Every app the caller's publisher org owns, on either creation track (a web-created app or a CLI-pushed submission).

Settings

FieldScopeDescription
platformSettings read:settings Display settings for the profile in scope.

Viewer & Identity

FieldScopeDescription
me — (ungated identity) The currently authenticated remote app, mirroring GET /api/apps/me.
viewer — (ungated identity) Identity of the calling principal.

Write fields (Mutation) — 38 total, batch-first

Every mutation takes a list and returns a list — settling ten ledger lines or checking in twenty guests costs one port call, not N. Input types map 1:1 to the Ports\Data DTOs.

App Ops

MutationScopeDescription
deleteAppSecret(appId: String!, key: String!) app principal only Delete one hosted secret.
replayAppDelivery(deliveryId: Int!) app principal only Re-dispatch one logged delivery as a FRESH delivery with its own signature and its own retry schedule; the original row is untouched.
setAppSecret(input: AppSecretInput!) app principal only Upsert one hosted secret for a platform-hosted app in the caller's publisher namespace.
syncAppSchedules(schedules: [AppScheduleInput!]!) app principal only Replace the calling app's declared schedules from its manifest, and read the result back in the same round trip.

Bookings

MutationScopeDescription
cancelBooking(reference: String!, reason: String) write:bookings Cancel a booking and release its dates.
createBooking(input: BookingRequestInput!) write:bookings Open a booking.
rescheduleBooking(reference: String!, checkInDate: String!, checkOutDate: String!) write:bookings Move a booking to new dates and re-price it at them.

Customers

MutationScopeDescription
createBadgeType(name: String!) UNDOCUMENTED Create a customer type OWNED BY THIS PROFILE (a membership badge such as Doctor or Gold), or return the existing one of that name.
ensureParties(drafts: [GuestDraftInput!]!) write:customers Resolve a batch of guest drafts to platform parties, matching on phone then email and creating only the genuinely new ones.
expirePartyType(partyId: Int!, typeSlug: String!) UNDOCUMENTED End an app-granted customer-type link now.

Discounts

MutationScopeDescription
discountDeleteRule(ruleId: Int!) write:discounts Delete one rule the app owns.
discountUpsertRule(input: DiscountRuleInput!) write:discounts Create (`id: null`) or update one rule the app OWNS, in the host's own discount engine — so a rule an app authored evaluates, prints and reconciles exactly like one the merchant configured by hand.

Extensions

MutationScopeDescription
moveInstalledAppExtension(installedAppId: Int!, handle: String!, target: String!, delta: Int!) user principal only (capability varies by surface) Move a placement one step within its slot.
setInstalledAppExtensionEnabled(installedAppId: Int!, handle: String!, target: String!, enabled: Boolean!) user principal only (capability varies by surface) Gate 4: switch one extension on or off at one target, creating the placement row when the surface had none (which is how a storefront block is placed from the console rather than from the page builder).
setInstalledAppExtensionSettings(installedAppId: Int!, handle: String!, target: String!, settings: String!) user principal only (capability varies by surface) Persist merchant-filled settings for one placement.
setInstalledAppSurfaceEnabled(installedAppId: Int!, surface: String!, enabled: Boolean!) user principal only (capability varies by surface) Gate 3: flip the merchant's master toggle for one surface of one app — "this app may contribute to Checkout at all".

Ledger

MutationScopeDescription
ledgerPost(entries: [LedgerEntryInput!]!) write:ledger Post a batch of ledger entries (idempotent by reference).
ledgerReverse(references: [String!]!, reason: String) write:ledger Reverse previously posted entries by their original references with contra entries.

Locations

MutationScopeDescription
ensureLocations(drafts: [LocationDraftInput!]!) write:locations Resolve a batch of unit drafts to service locations, matching on (kind, name) and creating only the genuinely new ones.

Notifications

MutationScopeDescription
broadcast(event: String!, payload: String!) broadcast:events Broadcast a domain event to the merchant's browser on this app's private per-profile Reverb channel.
ensureNotificationType(input: EnsureNotificationTypeInput!) send:notifications Register a notification type so the dispatcher will not silently drop sends that name it.
notifyStaff(input: NotifyStaffInput!) send:notifications Notify every staff recipient of the profile in scope (owner + managers) using the channels each one prefers.
notifyUser(input: NotifyUserInput!) send:notifications Notify a single platform user.

Other

MutationScopeDescription
approveTillVariance(sessionRef: String!, reason: String) write:till_sessions — then ALWAYS refused (requires_in_person_pin) ALWAYS REFUSED, with `reasonCode: "requires_in_person_pin"`.
closeTillSession(sessionRef: String!, counted: [TillTenderAmountInput!]!, reason: String) write:till_sessions Close a session against a counted drawer, per tender.
messagingSendTemplatedEmail(toEmail: String!, templateKey: String!, data: String) send:messages (app) · section.orders (user) Send a templated email to a customer through the merchant's own template editor — the app names a template key and supplies variables; the merchant's template decides what the customer reads.
messagingSendTemplatedWhatsapp(toPhone: String!, templateName: String!, languageCode: String!, components: String) send:messages (app) · section.orders (user) Send a templated WhatsApp message to a customer through the merchant's connected WhatsApp channel.
moveStock(input: StockMovementInput!) write:inventory Record a consumption or return against the merchant's inventory ledger.
openTillSession(tillRef: String!, openingFloat: [TillTenderAmountInput!]) write:till_sessions Open a session on a named till, in the CURRENT VIEWER's name.
paymentBegin(request: PaymentRequestInput!) write:payments (app) · section.orders (user) Start a payment through the merchant's own gateway for the amount and reference the app supplies.
paymentChargeStoredInstrument(instrumentRef: String!, amountMinor: Int!, reference: String!, description: String) write:payments (app) · section.orders (user) Charge a card the guest previously authorised the merchant to keep, named by the opaque `instrumentRef` a previous receipt carried.
paymentRecordTenders(tenders: [TenderInput!]!) write:payments (app) · section.orders (user) Record money the merchant has ALREADY taken — cash in the drawer, a card swiped on their own terminal, a bank transfer seen on a statement.
paymentRefund(paymentId: String!, amountMinor: Int!, reason: String) write:payments (app) · section.orders (user) Record a refund intent against one of the app's own payments.
paymentRefundTender(originalReference: String!, amountMinor: Int!, refundReference: String!, reason: String) write:payments (app) · section.orders (user) Refund all or part of a tender this app recorded through `paymentRecordTenders`, writing the contra transaction platform-side.
paymentStartGateway(request: GatewayPaymentInput!) write:payments (app) · section.orders (user) Start a gateway payment for in-process first-party code, covering the `qr` flow and card-on-file as well as redirect.
recordTillMovement(sessionRef: String!, kind: String!, tenderKind: String!, amountMinor: Int!, reason: String!, reference: String!, categoryId: Int, source: String, sourceAccountId: Int, destinationAccountId: Int) write:till_sessions Record one manual movement on an open session's tender.

Pricing & Tax

MutationScopeDescription
sequenceReserve(documentType: String!, prefix: String!, outletId: Int) write:sequences (app) · section.orders (user) Reserve the next number in one of the merchant's document sequences, under the same row lock the host's own document creation uses.
sequenceReserveBlock(documentType: String!, prefix: String!, count: Int, outletId: Int) write:sequences (app) · section.orders (user) Reserve a contiguous BLOCK of numbers under a single lock, for a caller minting several documents at once.

SCOPE_DENIED error shape

A field your app lacks the scope for does not leak data — it returns a structured SCOPE_DENIED entry in errors[]:

{
  "errors": [
    {
      "message": "This action requires the write:ledger scope.",
      "extensions": {
        "category": "scopeDenied",
        "scope": "write:ledger",
        "field": "ledgerPost"
      }
    }
  ],
  "data": { "ledgerPost": null }
}

Example query (read)

curl https://business-dev.autohisab.com/api/apps/graphql \
  -H "Authorization: Bearer $APP_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ me { app_id scopes } ordersByLocations(locationRefs:[\"201\"]) { locationRef orders { id status } } }"}'

Example mutation (write)

curl https://business-dev.autohisab.com/api/apps/graphql \
  -H "Authorization: Bearer $APP_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query":"mutation($d:[GuestDraftInput!]!){ ensureParties(drafts:$d){ id matched created } }",
       "variables":{"d":[{"name":"Aarav","phone":"+9779801234567"}]}}'

Money fields on the read surface are integers in minor units (*_minor) — never floats. The schema is the same for every app; enforcement is per-field at resolution time.

Remote app

8a · Session tokens & direct API

Call the API straight from your iframe. Requests used to hop through the host over postMessage — two serializations and a slice of the merchant's main thread per call. Now your frame fetches the GraphQL endpoint directly with a short-lived session token.

// The SDK does this for you — request() prefers the direct path
// whenever the host announces capabilities.directApi.
const token = await bridge.getSessionToken();

await fetch(`${ctx.origin}/api/apps/graphql`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
  body: JSON.stringify({ query: '{ me { appId } }' }),
});

Token refresh

getSessionToken() returns the cached token while more than 60 seconds of validity remain, and otherwise asks the host for a fresh one. Concurrent callers share a single in-flight refresh, so a burst of parallel queries triggers one refresh, not twenty. A long-lived iframe can no longer end up holding a stale token.

What the platform checks

  • Origin — CORS is an allow-list of exactly your registered app origin. Never a wildcard, so the token is useless from anywhere else.
  • Signature and expiry — HMAC-verified, short TTL.
  • Audience — the token is bound to one app id and one profile id; presenting it for another merchant fails.
  • Scopes — identical enforcement to the bearer-token path. Direct access is a faster transport, not a bigger permission.

Rate limits & retries

Responses carry X-RateLimit-* and, when throttled, Retry-After. The SDK auto-retries idempotent GET-shaped calls on 429/503 with jittered exponential backoff (max 3 attempts) and honours Retry-After. Writes are never auto-retried — retrying a mutation for you would risk duplicating it.

The postMessage proxy still works and remains the fallback wherever direct access isn't available, so nothing breaks if the capability is absent.

Remote app

8b · Lists, pagination & incremental sync

You can now enumerate, not just hydrate. The API previously answered only "give me these ids", which meant an app could never perform an initial sync — it could only look up records a webhook had already told it about. List queries close that gap.

query Customers($after: String) {
  customers(first: 100, after: $after) {
    nodes { id name phone email }
    pageInfo { hasNextPage endCursor }
  }
}

Available across the core domains — customers, orders, items, CMS entries, employees, media, and the chart of accounts and outlets. first is capped at 100 server-side.

Cursors are keyset, and opaque

Pagination walks by (updated_at, id) rather than by offset, so pages stay correct while records are being written underneath you — no skipped or repeated rows halfway through a sync. Treat endCursor as an opaque string: store it, send it back, never parse it.

The sync pattern

// 1. On install: walk everything once.
let after = null;
do {
  const page = await gql(CUSTOMERS, { after });
  await upsert(page.nodes);
  after = page.pageInfo.endCursor;
} while (page.pageInfo.hasNextPage);

// 2. Forever after: webhooks for low latency…
//    …plus a periodic reconcile with updatedAtMin as the safety net.
await gql(CUSTOMERS, { updatedAtMin: lastSyncedAt });

updatedAtMin is what makes the reconcile cheap. Drive it from a [[schedules]] tick (§6a): webhooks give you speed, the walk gives you correctness, and the combination survives an outage on either side.

Payloads are thin by design. A webhook tells you what changed — id, timestamp, sequence, changed field names — not the whole record. Hydrate through these queries with your scopes. That keeps personal data out of delivery logs, keeps what you read current, and keeps permission checks in exactly one place.
See it running in the Showcase app: Sync /sync

Reference — shared by both tracks

9 · Hooks

Domain events are bridged automatically to hooks via a wildcard listener, so any App\Events\* class becomes callable. The catalog below is rendered live from HookCatalog — the same list autohisab-business hooks returns.

Dispatch mechanism differs by domain. Order, party and account events flow through the platform's Verb-based event bus (transactional, fires after commit). Message, call and catalog events are dispatched through classic Laravel events. The wire format your webhook receives is identical — the difference is only in timing guarantees on the host side. cart.totals is a filter hook: it mutates a value the platform then consumes, not a notification.
Hook Category Type Description
order.created Orders event An order or transaction was created.
order.updated Orders event An order or transaction was updated.
order.status.changed Orders event Order or transaction status changed (created, paid, shipped, cancelled, refunded).
order.item.cancelled Orders event An individual order line item was cancelled.
order.item.quantity.changed Orders event An order line item quantity was adjusted.
customer.created Customers event A customer was created.
customer.updated Customers event A customer was updated.
customer.deleted Customers event A customer was deleted.
customer.subscription.changed Customers event A customer subscription was created or its billing state moved (paid, renewed, cancelled, expired). Thin payload: id, party_id, profile_plan_id, status, payment_status, ends_at.
item.created Catalog event A catalog item was created.
item.updated Catalog event A catalog item was updated.
item.deleted Catalog event A catalog item was deleted.
inventory.level.changed Catalog event An item stock level changed at a location.
price.changed Catalog event An item price moved (price, cost price, or compare-at price). Fires alongside item.updated so a price-watching app need not diff every item change.
cms.entry.published Content event A CMS entry became live. The cache-purge signal for any app-served section that renders the merchant&#039;s content: drop what you cached for `path` and for the entry&#039;s module listing. Carries ids and routing only, never the body.
cms.entry.updated Content event A live or draft CMS entry was saved with changes, without changing whether it is live. `changed` names the columns that moved.
cms.entry.unpublished Content event A CMS entry stopped being live. The other half of the cache-purge pair: whatever you cached for `path` is now a 404 on the storefront.
cms.entry.deleted Content event A CMS entry was deleted. Emitted for drafts too — an index that keeps a removed entry keeps serving a dead link.
media.uploaded Media event A media file was uploaded to the profile library.
employee.created HR event An employee record was created.
employee.updated HR event An employee record was updated.
employee.archived HR event An employee record was archived.
app.uninstalled Lifecycle event This app was uninstalled from the profile — purge the merchant&#039;s data. Mandatory delivery.
app.scopes.updated Lifecycle event The scopes granted to this app changed.
app.style.updated Lifecycle event The merchant changed this app&#039;s Style Surface values. A CACHE-PURGE signal, not the delivery channel: the resolved variables arrive with each render (signed token claim, admin CONTEXT, or live STYLE_TOKENS message). Drop any HTML you cached for the named surface, and make sure style_rev is part of your render cache key.
profile.data.erased Lifecycle event The merchant erased their profile data — purge everything you hold. Mandatory delivery.
schedule.tick Scheduling event A schedule your app declared in its manifest fired. Delivered per install, per interval, to the app that declared it — you do not subscribe to this event, you declare a [[schedules]] entry. Payload carries the schedule name, profile id, and scheduled_at.
party.balance.changed Finance event A customer/party balance changed (credit, debit, adjustment).
account.balance.changed Finance event An account balance changed.
new.in.app.notification Notifications event A new in-app notification was created for a user.
new.message.sent Messages event A new chat/message was sent.
service.location.status.changed Operations event A service location (table/room/station) status changed.
call.initiated Calls event A call was initiated.
call.ended Calls event A call ended.
booking.created Bookings event A booking was created (any status, including a hold).
booking.confirmed Bookings event A booking moved to confirmed — the inventory is now committed.
booking.checked_in Bookings event A guest checked in and is in-house.
booking.checked_out Bookings event A guest checked out; the room is released and due for turnover.
booking.cancelled Bookings event A booking was cancelled and its dates released. Carries the cancellation reason.
booking.no_show Bookings event A confirmed arrival never appeared and was marked no-show.
pms.ari.changed Bookings event Rates, availability or restrictions moved for one channel. Carries only the (room type, date) cells that changed, with the channel&#039;s markup already applied.
till_session.opened Balance Control event A till session was opened — a named person is now accountable for a drawer. Carries session_ref, till_ref, status and the actor; never the opening float.
till_session.closed Balance Control event A till session was closed against a counted drawer. `status` distinguishes a clean close from one left `pending_approval` by an over-tolerance variance — the count is kept either way. Carries no money.
till_session.force_closed Balance Control event A till session was force-closed without a count (a shift abandoned, a period rolled shut). The signal that any expected figure an app cached for this session is now final and unverified.
till_session.handed_over Balance Control event A till session changed hands: one cashier handed the drawer to the next. Accountability moved, the drawer did not.
till_movement.recorded Balance Control event A row was written to the till ledger — a pay in, pay out, drop, pickup, correction, sale tender or reversal. Carries movement_ref, session_ref and kind; re-read the amount through `tillMovements`.
till_settlement.recorded Balance Control event A till account was banked: a cash deposit, a card batch, a gateway payout. Carries settlement_ref, till_ref, kind and status.
till_settlement.undone Balance Control event A settlement was taken back — the batch never landed. The half of the pair an integration MUST handle: without it, money you reported as banked stays banked in your records and not in the merchant&#039;s.
cart.totals Cart filter Filter: modify cart totals before checkout (fees, discounts, points). Payload carries party_id, party_types and the full lines[] snapshot alongside grand_total; the host honours grand_total as the final payable.
pricing.line Pricing filter Filter: replace the price of individual cart/booking lines before discounts and totals. Minor units in and out. Set price_minor and price_source; the lowest replacement across apps wins.
reports.settlement.value_sources Reports filter Filter: contribute a &quot;Settle on&quot; price basis to the outlet settlement report. Each source names a key and label and supplies SQL for the line value in minor units (and an optional flag reason). Bundled in-process apps only.
reports.settlement.business_basis Reports filter Filter: the ONE price basis a business settles its outlets on (custom | selling | cost). Payload: the host default basis key; context: profile_id. Return a basis key; an unknown key falls back to the default. Bundled in-process apps only.
pricing.party Pricing filter Filter: does your app price this party at all (a member tier, a party price list)? Payload party_id, priced (bool). Set priced=true; apps may only widen. Asked once when a till loads, for its default (walk-in) party, so a walk-in cart stays request-free unless that party is priced.
discount.line_eligibility Pricing filter Filter: decide whether a coupon may discount each cart line (lines carry price_source). Set eligible=false on a line, or refusal {reason, message} to refuse the coupon for the order. Apps may only narrow.
receipt.sections.available Printing filter Filter: list the receipt sections your app can add ({key: &quot;&lt;app id&gt;:&lt;section&gt;&quot;, label, description}). The merchant switches each on in receipt settings.
receipt.sections Printing filter Filter: append print blocks (existing palette only) to a customer receipt for the sections the merchant enabled. Payload: order_id, enabled[], storefront_url, blocks[]. Append only.
order.lines.reserve Cart filter Filter (in-process bundled apps only; never delivered as a webhook): reserve what an app-priced line consumes (loyalty stamps, a voucher) on the just-created order, inside its transaction. Payload: order_id, profile_id, party_id, total_minor, lines[] (index, item_id, quantity, price_source, intent). Answer reserved {index: ref}; an unnamed line is sold at its normal price. Optional refused {index: message} is shown to the cashier.
cart.party.changed Cart event The customer/party attached to an in-progress cart changed (selected, swapped or cleared). The signal to invalidate any per-request price memo an app holds.
cart.line.added Cart event A line was added to an in-progress cart.
cart.line.removed Cart event A line was removed from an in-progress cart.
sale.lines.created Orders event Event (in-process bundled apps only; never delivered as a webhook): sale lines were written — a new sale, or lines ADDED to an open one (a quantity edit on an existing line does not re-fire). Emitted once per write batch, after the database commit, so a rolled-back order never fires. Payload: profile_id, transaction_id, lines[] (transaction_item_id, item_id, variant_id, owner_outlet_id, currency). The moment to freeze a per-line snapshot at the time of sale.
order.paid Orders event An order was settled in full — the point at which a pricing or loyalty app may bank a redemption.
app.payment.succeeded Payments event A payment your app started through PaymentPort completed. Delivered ONLY to your app (via Hooks::emitToApp — never the broadcast path), never to any other app installed on the profile. Payload carries your own app_reference AND the `metadata` you supplied when you started the payment (minus the two routing keys the platform added), so you can settle the exact row it paid for without keeping a second reference table. Also carries payment_id, gross_minor, currency, gateway, gateway_reference and profile_id.
app.payment.failed Payments event A payment your app started through PaymentPort failed or was abandoned at the gateway. Delivered ONLY to your app (via Hooks::emitToApp — never the broadcast path), never to any other app installed on the profile.
PHP (Track A). Register via $registrar->on('order.status.changed', fn(array $payload, AppContext $ctx) => ...). Prefer the typed Flashmandu\AppSdk\Hooks\HookName backed enum. Remote (Track B). List the events in your manifest's subscribed_events and the platform delivers each as a signed webhook (section 6).

Reference — shared

10 · Scopes

Scopes are the permission model. Apps request them in their manifest; merchants grant them at install; the platform gates every privileged call against the granted list. Elevated scopes are denied by default and must be granted explicitly. An operator-side allowlist (config/apps.php → operator_allowed_scopes) can further restrict what any remote app may be granted on this box.

SDK scopes (manifest-declarable)

ScopeEnum caseStatus
read:orders ReadOrders Standard
write:orders WriteOrders Standard
read:customers ReadCustomers Standard
write:customers WriteCustomers Standard
read:bookings ReadBookings Standard
write:bookings WriteBookings Standard
manage:storefront ManageStorefront Standard
manage:payments ManagePayments Elevated — denied by default
read:content ReadContent Standard
read:employees ReadEmployees Standard
read:media ReadMedia Standard

Port scopes (platform resource ports)

These gate the platform's resource ports (catalog, ledger, locations, notifications, broadcast, etc.). They live platform-side ahead of the next SDK release; a port scope can only be granted at install (first-party apps via config/apps.php → default_port_scopes) and cannot yet be requested by a manifest.

ScopeEnum caseNotes
read:catalog ReadCatalog Read
read:bookings ReadBookings Read
write:bookings WriteBookings Read
read:content ReadContent Read
read:locations ReadLocations Read
write:locations WriteLocations Elevated
read:settings ReadSettings Read
write:customer-types WriteCustomerTypes Read
read:discounts ReadDiscounts Read
write:discounts WriteDiscounts Read
read:ledger ReadLedger Read
write:ledger WriteLedger Elevated
write:inventory WriteInventory Read
send:notifications SendNotifications Elevated
broadcast:events BroadcastEvents Elevated
read:employees ReadEmployees Read
verify:customer-phones VerifyCustomerPhones Read
read:media ReadMedia Read
write:media WriteMedia Read
receive:search ReceiveSearch Read
write:print WritePrint Read
read:devices ReadDevices Read
read:pricing ReadPricing Read
read:tax ReadTax Read
write:sequences WriteSequences Read
read:payments ReadPayments Read
write:payments WritePayments Read
read:till_sessions ReadTillSessions Read
write:till_sessions WriteTillSessions Elevated
send:messages SendMessages Read

Reference — shared

10a · Schema versioning policy

Two shapes an app declares are public API surfaces this platform build parses and renders: the section schema (the element_tree a storefront section is built against, gated by Flashmandu\AppSdk\Storefront\SectionSchema) and the design token schema (the fixed vocabulary a theme speaks, gated by App\Services\Storefront\Themes\DesignTokenSchema). Both are consumed by code this platform does not control, so a breaking change to either is a breaking change to someone else's shipped app. This section is the contract for how that change is allowed to happen.

1. Additive-only within a version

A schema version number never changes meaning after it ships. Within a version you may add a new optional field, a new enum member, a new token axis or slot with a sensible default — anything an app that ignores it can safely not know about. You may never repurpose an existing field, tighten a previously-permissive validation, or change what a value already in the wild means. If a change is not additive, it is not a patch to the current version — it is a new version.

2. A new version means N and N−1 for six months

Bumping SectionSchema::CURRENT (or DesignTokenSchema::VERSION) does not retire the previous version on the same commit. The renderer keeps accepting a declared schema one major version behind current for a minimum of six months from the day the new version ships — long enough for a third-party app author, who does not work on this platform's release cadence, to notice the deprecation and re-publish. Only after that window closes does the old version's support get removed, as its own deliberate change with its own changelog entry (see the SDK changelog linked below).

3. The error names both versions

When a declared schema version falls outside what this build supports, the platform fails loudly and the exception message names both the version the app declared and the version(s) this build supports — never just "unsupported schema" with no numbers. That is what lets an app author tell "you are ahead of this platform" apart from "your data is malformed" without opening a support ticket. DesignTokenSchema::assertSupported() is the reference shape:

Design token set declares design_tokens_schema [3]; this build supports version(s) [2] and authors version [2].

4. Fail loud, never silently downgrade — for a surface apps declare

DesignTokenSchema throws DesignTokenValidationException on an unsupported version rather than coercing it to the nearest known one. That is the correct behaviour any time the schema is declared by code this platform does not control: silently reinterpreting a third-party app's declared version as something else is silent data corruption from that app's point of view — a field it thinks it set is quietly ignored or reinterpreted, and nothing tells the author. The section schema follows the same rule for the same reason.

This is deliberately not how every schema in this codebase behaves, and that inconsistency is not an oversight to paper over here — it is two different situations that call for two different rules. Flashmandu\Forms\Registry::SCHEMA_VERSION_PAGED clamps a form's detected version down to the highest one this build knows rather than throwing. That schema is entirely first-party: every reader of it is CMS code shipped in the same deploy as every writer of it, so there is no external author to silently betray, and clamping is just forward-compatible internal migration bookkeeping. The rule is: a schema with a third-party author fails loud on a version it cannot honour; a schema with only first-party authors and readers may downgrade silently, because "silent" only matters when someone outside this codebase could be surprised by it. If you are adding a new versioned schema, decide which category it is in before you decide how it fails.

5. Announcing and discovering a deprecation

  • The day a new version ships, the previous version's support window (start date, six-month end date) is recorded as a new entry in docs/app-platform-schema-changelog.md in the main platform repo — the SDK-facing half of this policy, with the full version history.
  • A section or theme built against the deprecated version keeps rendering for the full six-month window, but the version that superseded it is announced where an app author is already looking: this page (updated the same day), the changelog above, and — once a deprecated version is actually in flight — a note on the corresponding webhook delivery inspector row for any render that used it, so the discovery path is data the author already checks rather than a changelog they have to remember to watch.
  • The reference apps (tours / orbit / interactive-map / showcase) are re-published against the new version within the same milestone the version ships in, so the "how do I migrate" answer is always a real, running diff rather than a description.

See also: §11a Extension surfaces & gates for where a section's element_tree is consumed, and §19 Embed blocks (deep dive) for the unrelated, per-app schema_version an embed block's own settings payload carries and bumps to run onSettingsMigrate() — a narrower, single-app concern this policy does not govern.

Reference — shared

11 · Extension Points

Extensions are the UI surfaces an app can attach to. Each targets a specific surfacing point and declares how the host renders it. Scope gates are evaluated before surfacing — a missing scope hides the extension entirely.

TargetSurfaceEnum case
admin.order.detail.block Admin AdminOrderDetailBlock
admin.order.detail.action Admin AdminOrderDetailAction
admin.order.index.action Admin AdminOrderIndexAction
admin.item.detail.block Admin AdminItemDetailBlock
admin.item.detail.action Admin AdminItemDetailAction
admin.po.detail.block Admin AdminPoDetailBlock
admin.customer.detail.block Admin AdminCustomerDetailBlock
admin.customer.history.block Admin AdminCustomerHistoryBlock
admin.expense.index.action Admin AdminExpenseIndexAction
admin.order.index.selection-action Admin AdminOrderIndexSelectionAction
admin.item.index.selection-action Admin AdminItemIndexSelectionAction
admin.order.detail.print-action Admin AdminOrderDetailPrintAction
pos.cart.action POS PosCartAction
storefront.section Storefront StorefrontSection
pos.customer.block POS host-only — no SDK case yet
pos.cart.line.action POS host-only — no SDK case yet
pos.receipt.block POS host-only — no SDK case yet
pos.home.tile POS host-only — no SDK case yet
pos.till.block POS host-only — no SDK case yet
pos.party-pill.block POS host-only — no SDK case yet
pos.party-sheet.action POS host-only — no SDK case yet
pos.party-sheet.section POS host-only — no SDK case yet
admin.customer.detail.action Admin host-only — no SDK case yet
admin.customer.list.action Admin host-only — no SDK case yet
admin.customer.list.column Admin host-only — no SDK case yet
admin.customer.list.row-action Admin host-only — no SDK case yet
admin.customer.list.filter Admin host-only — no SDK case yet
account.page Account host-only — no SDK case yet
account.nav Account host-only — no SDK case yet
account.order.detail.block Account host-only — no SDK case yet
checkout.summary.block Checkout host-only — no SDK case yet
storefront.product.block Storefront host-only — no SDK case yet
admin.order.detail.should-render Admin host-only — no SDK case yet
admin.item.detail.should-render Admin host-only — no SDK case yet
admin.po.detail.should-render Admin host-only — no SDK case yet
admin.customer.detail.should-render Admin host-only — no SDK case yet
admin.customer.history.should-render Admin host-only — no SDK case yet
pos.customer.should-render POS host-only — no SDK case yet
pos.receipt.should-render POS host-only — no SDK case yet
pos.till.should-render POS host-only — no SDK case yet
pos.party-pill.should-render POS host-only — no SDK case yet
account.order.detail.should-render Account host-only — no SDK case yet
checkout.summary.should-render Checkout host-only — no SDK case yet
storefront.product.should-render Storefront host-only — no SDK case yet

Block vs iframe rendering

  • › Block targets (e.g. admin.order.detail.block) render inline server-side in the host. Track A apps provide a local Blade view; Track B apps provide a remoteUrl that the host loads in a nested iframe at that position.
  • › Action targets (e.g. admin.order.index.action, pos.cart.action) render as a button/entry point that opens the app's iframe surface when clicked.
  • › Storefront targets (storefront.section) contribute composable tree sections to the storefront — built from existing page-builder elements for three-renderer parity.

Declaring extensions — Track A (PHP manifest)

use Flashmandu\AppSdk\Extensions\Extension;
use Flashmandu\AppSdk\Extensions\ExtensionTarget;
use Flashmandu\AppSdk\Extensions\Extensions;

// In your manifest():
extensions: new Extensions(extensions: [
    new Extension(
        target: ExtensionTarget::AdminOrderDetailBlock->value,
        label: 'Loyalty insights',
        block: 'acme-loyalty::blocks.order-detail', // local Blade view
        requiredScopes: ['read:customers'],
    ),
    new Extension(
        target: ExtensionTarget::StorefrontSection->value,
        label: 'Points widget',
        render: '<div x-data="loyaltyWidget()">...</div>', // server-rendered fragment
    ),
]),

Declaring extensions — Track B (remote install)

Remote apps declare extensions in the platform's config/apps.php → remote_apps entry; they are persisted onto installed_apps.extensions at install time and rendered through the host shell via remoteUrl.

// config/apps.php → remote_apps entry
'extensions' => [
    [
        'target' => 'admin.order.detail.block',
        'label'  => 'Loyalty insights',
        'remoteUrl' => 'https://your.app/embed/order-detail',
        'required_scopes' => ['read:customers'],
    ],
],

Reference — shared

11a · Extension surfaces & gates

§11 lists the targets. This section is the machinery behind them: which surface a target belongs to, what that surface will and will not let your extension do, the four gates every extension clears before it renders, and what "disabled" actually means on the page. The short version, and the one rule that explains most of the rest: the surface owns the security envelope, not the extension. Your app declares what it wants; the surface decides what it gets.

11a.1 · The six surfaces

A target string is {surface}.{resource}.{location}.{kind}. The surface is the first dot-segment, so admin.order.detail.block is an admin extension and storefront.section a storefront one. Six surfaces exist (ExtensionSurface); a target whose first segment is none of them does not resolve, and is treated as "not renderable" rather than defaulting to something permissive.

Surface Customer-facing Plan capability (gate 1) Master toggle default (gate 3) Placement at install (gate 4)
App home app No app.extensions_admin On No row created
Admin admin No app.extensions_admin On Row created enabled
POS pos No app.extensions_pos On Row created enabled
Storefront storefront Yes app.embed_blocks Off No row — the page builder creates it
Checkout checkout Yes app.extensions_checkout Off Row created disabled
Customer account account Yes app.extensions_account Off Row created disabled
Agent tools (MCP) mcp No app.extensions_mcp Off — the merchant opts in before an app acts through their agent No row created — only the gate-3 toggle applies

Gate 3's storefront value is not stored in installed_apps.surfaces_enabled with the others — it routes to the shipped embed_blocks_enabled column, so the embed-block toggle a merchant already knows and the new per-surface toggle can never drift apart.

11a.2 · The target registry, and why it is not the SDK enum

The SDK's ExtensionTarget enum (§11) is a vendored, versioned contract whose values are persisted in production installed_apps.extensions rows, so it is never renamed and never re-cased. The host's registry, ExtensionTargets::all(), is the superset the gates actually consult:

ExtensionTargets::all()
    = ExtensionTarget::cases()          // the published SDK contract
    ∪ ExtensionTargets::HOST_TARGETS    // targets the host renders before the SDK ships a case
    ∪ { shouldRenderCompanion(t) : kind(t) === 'block' }

The indirection is deliberate: ExtensionTarget::from() throws on an unknown value, so a resolver built on it could never accept a target the pinned SDK had not yet declared — which is exactly the situation whenever the host adds a surface first. Use ExtensionTargets::isKnown() on every hydration path.

Host targets beyond the SDK enum include:

Host targetKindRendered by
admin.item.index.selection-action selection-action <x-apps::selection-action-slot> on the Items index
admin.order.detail.print-action print-action <x-apps::print-action-slot> on the order detail page
admin.customer.list.column column ListContribution on the Customers list: one extra column, cells() called once per page with the page's ids
admin.customer.list.row-action row-action ListContribution on the Customers list: row buttons, plus one page-level companion (render()) under the list
admin.customer.list.filter filter ListFilterContribution on the Customers list: options are party type slugs; the host applies "carries this badge" itself
account.nav nav <x-apps::slot> at the end of the storefront account menu (<x-sf::account-sidebar>): the block returns <x-sf::account-nav-link> entries; the context is the active menu key. Account pages are per-visitor and never page-cached.
admin.order.index.selection-action selection-action Nothing — no call site. See the honest caveat below.
Caveat, stated rather than buried: admin.order.index.selection-action currently renders nowhere. The orders index has no bulk-selection bar, so there is no host surface to attach a bulk action to. The target is declared, resolvable and gated correctly — and an extension bound to it will render the moment that bar exists — but today it contributes no UI. The sibling admin.item.index.selection-action does have a call site. Do not ship an order bulk action against this target expecting it to appear. Guarded by tests/Feature/Apps/ExtensionTargetCoverageTest.php, which fails when an SDK enum case has no render site; host-only targets are not yet in its blast radius, which is why this one could sit dead.

Target kinds

The kind is the last dot-segment and describes the slot's shape: block · action · selection-action · print-action · tile · modal · section · should-render. Every …​.block target has an implicit …​.should-render companion generated for it — it is not listed anywhere, it is derived by replacing the kind.

11a.3 · The four gates, in order

Every extension clears four gates before it renders, evaluated in this order and short-circuiting on the first failure. The order matters and is not arbitrary: a merchant told "you haven't enabled Admin for this app" when the real problem is their plan would go and flip a switch that changes nothing.

# Gate Question it answers Where it lives
1 Plan Does this profile's plan include extensions on this surface at all? The surface's planCapability(), resolved through the host's entitlement layer
2 Scopes Did the merchant grant the scopes this extension declares, at OAuth? Declared scopes + port scopes vs the install's granted set
3 Surface Has the merchant turned this app's surface on? installed_apps.surfaces_enabled (storefront → embed_blocks_enabled)
4 Placement Is this extension enabled at this target? The installed_app_extensions row for (install, handle, target)
5 App check (per-resource) Does the app itself want to draw for this record? The target's should-render companion — see 11a.6

Gates 1–4 are cached per (profile, target) for the request. The fifth is deliberately not a peer of the other four: it depends on the record being rendered, so it can never be folded into that memo. It is applied on top, per resource, by ExtensionResolver::forResource(). Gate 4 costs one extra query per profile per request — the placement rows for the whole tenant are loaded once and grouped, not read per install.

Naming the gate is the point. The shipped storefront model had three of these and told partners to "check them in that order", which is a support burden rather than an answer. Every miss now carries which gate failed and a merchant-facing sentence, so the management screen can say "Gift Message is not showing on checkout because you haven't enabled Checkout for this app". That explanation exists in exactly one place — ExtensionGateReporter, which feeds the management screen and host editors. It never reaches a rendered page.

11a.4 · The install-default asymmetry

Gate 4's default at install time is asymmetric by blast radius, and that asymmetry is the single most opinionated thing in this design:

  • › Admin and POS create a row that is enabled. These are staff-only surfaces. Making a merchant install a loyalty app and then hunt for a second toggle before anything appears is hostile, and the cost of being wrong is that a staff member sees a panel they did not ask for.
  • › Checkout and account create a row that is disabled. A customer-facing purchase flow is never opt-out. The merchant turns it on deliberately, having decided the app should be in front of their buyers.
  • › Storefront creates no row at all. Placing the block in the page builder is what creates it. This is not a new rule — it is already exactly how embed blocks work, and the CMS depends on it. Consequently a storefront extension with no row is not "disabled": the page builder's own placement is the record, so absence means "not placed", and absentPlacementIsEnabled() is true for it.
  • › Everywhere else, a missing row means the install predates this table, and is backfilled to the surface default rather than silently disappearing. Syncing defaults is idempotent: an existing row is never touched, so re-running install (an upgrade, a re-auth) can never flip a merchant's toggle back on.

11a.5 · Disabled means nothing, not a gap

A gated-out extension emits literally nothing. No marker <div>, no HTML comment, no reserved height, no frame route — and because the frame route is never emitted, no session token is ever minted for it. A disabled extension is byte-for-byte indistinguishable from one that was never installed. That property is what makes it safe to put third-party extensions on a customer-facing surface at all.

The opposite behaviour is a deliberate, explicit opt-in for host editor surfaces only — the page builder, the POS grid editor, the management screen:

{{-- Normal render: gated-out extensions contribute nothing. --}}
<x-apps::slot target="admin.order.detail.block" :context="$order" />

{{-- Editor render: greyed, labelled placeholder naming the app and the reason. --}}
<x-apps::slot target="storefront.section" :editor="true" />

:editor="true" renders a dashed placeholder carrying the app name, the target, a "Not showing" badge and the merchant-facing reason from the failed gate. It is opt-in rather than inferred precisely so it cannot leak onto a customer-facing page by accident. A slot also renders nothing when no current_profile is bound, rather than resolving against an unknown tenant.

11a.6 · should_render — parsed, recorded, and always true for remote apps

A block target can declare a companion boolean so the app can decline to draw for a particular record — "no loyalty panel for a guest order" — without paying a render. You declare it as should_render on the targeting entry; the host derives the companion target name by replacing the kind.

Caveat, stated plainly: for a remote app this currently always resolves true. The declaration is parsed, validated and recorded, and it is honoured today only for an in-process contribution implementing ShouldRenderContribution. Answering it for a remote app would mean a synchronous cross-origin fetch per slot per pageview — which is a worse problem than the cosmetic one it solves. The fetch-and-cache layer is Phase 2. Until then, assume your block will be rendered and decide inside the frame. A companion that throws is also treated as "yes". An app's opinion about whether to draw itself must never be able to blank the host's page.

11a.7 · The sandbox comes from the surface

Every iframe-mode extension is served sandbox="allow-scripts", on every surface, and nothing an app writes in its manifest can widen that. No allow-same-origin, no allow-forms, no allow-popups, no top-level navigation, no allow-modals (which is why confirm() silently returns false — see §D5).

The storefront's scripts-only envelope is the template every other surface copies. This is a tightening: the admin slot previously shipped allow-scripts allow-forms allow-popups, which is an unrestricted window.open primitive plus a cross-site POST from inside the admin — a looser envelope than the storefront's, on the surface that carries staff session context.

Capability allowlists are per surface, enforced at registration

Each surface publishes the host capabilities it permits. A capability your extension declares that the surface does not allow is dropped at registration, not at render, and is reported back to the merchant on the management screen — so a partner can see plainly why their toast() call does nothing on the storefront.

SurfaceAllowed capabilities (Phase 1)
App home identity, resize, navigate, error, toast, modal, picker, page_chrome, set_sidebar_links, loading, graphql
Admin identity, resize, navigate, error, toast, modal, picker, loading, graphql, print
POS identity, resize, navigate, error, toast, modal, picker, loading, graphql, cart_attributes, fee_propose, scanner, print, storage, connectivity
Storefront identity, resize, navigate, error
Checkout identity, resize, error, network_access, block_progress, cart_attributes, fee_propose
Customer account identity, resize, navigate, error, network_access

The storefront list is the shipped EmbedBlockDefinition::ALLOWED_CAPABILITIES, verbatim and unchanged. Note what checkout does not get: no navigate, no toast, no modal — nothing that can interrupt or redirect a buyer mid-purchase.

⚠ Known gap — an extension in a slot has no bridge yet

The capabilities in the table above describe the surface's allowlist, not what an iframe extension can reach today. Right now only the full-page embed at /apps/{app}/embed has a working App Bridge. An iframe-mode extension rendered into a host slot gets no CONTEXT reply, so bridge.ready() never resolves — it hangs rather than failing. Nothing else works either: no graphql(), no toast(), no navigate(), no token refresh. Write slot extensions to render without the bridge, and do not await bridge.ready() at the top of a slot extension: it will never settle and your frame will stay blank. Reach the platform API from your own server using your app's own credentials instead.

Why it is not a one-line fix, since the reason shapes what lands later. A slot renders a nested pair of frames: the host page holds an iframe pointing at the same-origin shim route, and the shim holds a second iframe pointing at your app. So your app's window.parent is the shim, not the admin page — stamping the host attributes on the outer iframe would not deliver a single message. And because the outer frame is sandbox="allow-scripts" with no allow-same-origin (§11a.7), the shim runs in an opaque origin: anything it forwarded upward would arrive as origin "null" and be dropped by the host's origin check, which is the guard the whole embed security model rests on.

There is a second, independent limit: the host script wires one [data-app-bridge-host] element and one channel id per page (document.querySelector, singular). A slot page can hold several app frames — several apps on one order detail page — so the extension bridge needs per-frame wiring, not the single-host model the full-page embed uses.

Both are tracked for the extension-bridge phase, where an extension gets a target-specific context — { target, instance_id, api_version, resource, settings, capabilities } — pushed on CONTEXT and re-pushed when the host's resource changes, rather than the full-page bridge's fetch-once model. Until that ships, treat the capability table as the ceiling a slot extension will eventually be allowed, not the floor it has now.

11a.8 · The unified [[extensions]] shape

One manifest table for every surface. The load-bearing idea is targeting: one extension, several targets, one codebase. A loyalty app that wants an admin block and a POS block and an account block declares itself once.

[[extensions]]
handle      = "loyalty-panel"
name        = "Loyalty insights"
description = "Points balance and tier for this customer."
category    = "customers"
icon        = "gift"
api_version = "2026-08"
capabilities = ["identity", "resize", "toast"]

  [[extensions.targeting]]
  target = "admin.order.detail.block"
  mode   = "iframe"                 # block | iframe | render-spec
  path   = "/embed/order-detail"    # resolved against the app's pinned origin
  should_render = "/embed/order-detail/should-render"

  [[extensions.targeting]]
  target = "pos.cart.action"
  mode   = "iframe"
  path   = "/embed/pos-cart"

[extensions.scopes]
required      = ["read:customers"]
required_port = ["read:ledger"]
  • › path is resolved against the app's one pinned origin (installed_apps.embed_origin, fixed at install) — that is why the unified shape carries no per-target origin. A payload that supplies a fully-qualified remoteUrl is used as-is.
  • › mode is declared rather than inferred from which field you populated, so a malformed manifest is a registration-time complaint instead of a render-time surprise. render-spec — a validated JSON description the host draws with its own components — is declarable now and renders in Phase 2; a render-spec binding resolves today but contributes no markup.
  • › A targeting entry whose target is not in the host registry is skipped, and an extension left with no valid bindings is dropped entirely. Hydration is total and silent by design: one malformed declaration must not take out your app's other extensions, or the merchant's page.
  • › handle is the placement row's stable key. Shapes that never had one get a derived handle — the target with dots collapsed to dashes (admin.order.detail.block → admin-order-detail-block).

How [[embed_blocks]] desugars into it

Storefront embed blocks (§2e, §19) are not a separate system — they are the unified shape with one target filled in. The rewrite happens at read time, in one reader, so nothing you have shipped needs to change:

[[embed_blocks]]                     ⇓  desugars to  ⇓
type         = "points-widget"       handle  = "points-widget"
name         = "Points widget"       name    = "Points widget"
block_origin = "https://your.app"    origin  = "https://your.app"
block_path   = "/blocks/points"      targeting = [ { target: "storefront.section",
capabilities = ["identity"]                          mode:   "iframe",
                                                     path:   "/blocks/points" } ]

The discriminator is unchanged from the shipped reader: an entry carrying both block_origin and block_path is an embed block; an entry carrying a targeting list is the unified shape; anything else with a bare target is the flat slot payload the CLI persists today. All three land on the same object, so every gate downstream reasons about one type.

The relationship also runs backwards: the CMS page builder still receives EmbedBlockDefinition objects, produced as a view over the unified definition. A definition built from a manifest object hands back that very instance, so a local app's declarations round-trip unchanged — the desugaring can only ever affect payloads it actually had to reconstruct.

Reference — shared

12 · Settings

Track A apps can declare a settings_schema in their manifest — an ordered list of SettingField DTOs. The host auto-renders a Flux form at /apps/{appId}/settings; values are persisted on InstalledApp::settings under the field's machine name. No per-app settings UI to build.

Remote (Track B) apps have no manifest in the host and therefore render their own settings UI inside their iframe (use the App Bridge UI Kit from section 5).

SettingField types

typeHost controlNotes
text flux:input (single line) Default.
number flux:input type="number" Numeric values.
boolean flux:checkbox Stored as bool.
select flux:select Requires $options as [value => Label].
color flux:input type="color" Hex color.
url flux:input type="url" Validated URL.

Declaring settings

use Flashmandu\AppSdk\Settings\SettingField;
use Flashmandu\AppSdk\Settings\SettingsSchema;

// In your manifest():
settingsSchema: new SettingsSchema(fields: [
    new SettingField(
        name: 'points_per_currency',
        label: 'Points per currency unit',
        type: 'number',
        default: 1,
        help: 'How many loyalty points a customer earns per 1 unit spent.',
        required: true,
    ),
    new SettingField(
        name: 'tier',
        label: 'Default tier',
        type: 'select',
        options: ['bronze' => 'Bronze', 'silver' => 'Silver', 'gold' => 'Gold'],
        default: 'bronze',
    ),
    new SettingField(name: 'accent_color', label: 'Widget accent', type: 'color', default: '#2563eb'),
    new SettingField(name: 'redemption_url', label: 'Redemption URL', type: 'url'),
    new SettingField(name: 'show_on_receipts', label: 'Show points on receipts', type: 'boolean', default: true),
]),

Reference — shared

13 · App Identity

Every app is identified by a publisher-namespaced slug of the form <publisher>/<slug>, e.g. acme/loyalty or flashmandu/property-booking.

  • › Uniqueness is bloom-filtered. The platform keeps a Redis-backed bloom bitmap of in-use slugs (and emails), checked at registration time. Misses are confirmed against the DB; the bloom is a fast pre-check, not the source of truth.
  • › Collision rules. Two different publishers may NOT claim the same slug under their own namespace if the bare slug is reserved; within a publisher namespace, slugs are unique. Pick a stable publisher prefix (your company / GitHub org) early — it cannot be renamed across an installed base without breaking installed apps.
  • › Case. Slugs are lowercase kebab-case. The leading namespace prefix is optional in development but required for marketplace submission.
# flashmandu.app.toml (Track B)
id = "acme/loyalty"        # publisher/slug

# Track A — AppManifest constructor
id: 'acme/loyalty',

Reference — shared

14 · Publishing

When your app is ready for review, the CLI is the only door to the marketplace:

autohisab-business push

push submits your manifest for operator review — it never touches production directly. An operator approves the listing, after which merchants can install it through the OAuth flow in section 7. For a platform-hosted app, deploy --target platform does the same and builds + pushes your image first (see below). The CLI verbs are deliberately distinct:

  • ›app dev — the no-push local loop. Runs against your own profile on this box; nothing leaves it.
  • ›app deploy --target platform — builds + pushes a Docker image and submits a versioned app for review. The full path for a platform-hosted app.
  • ›app push — submits the current manifest without rebuilding. Use it for metadata-only changes (scopes, URLs, pricing).

Identity is asked for once

The first push needs your name and contact email:

autohisab-business push --developer-name "Your Name" --developer-email you@example.com

Both are written back into flashmandu.app.toml on success, so every later push is just autohisab-business push.

Versions bump automatically

Each push submits a new version: the patch is bumped (0.1.0 → 0.1.1) and written back to your manifest once the submission is accepted — a failed push never burns a version number. Resubmitting the same version would leave the reviewer unable to tell two builds apart. Override when you need to:

autohisab-business push --version 1.0.0   # submit an exact version
autohisab-business push --no-bump         # resubmit the manifest version as-is

Your app id is namespaced for you

You declare only the leaf slug; the platform namespaces it under your publisher handle (owner-1/interactive-map) so two developers can ship the same slug without colliding. The canonical id is printed after a successful push — it is the id merchants install and the id a storefront App Block references.

What the reviewer sees

A submission carries your OAuth consent URL, webhook URL, requested scopes, declared [[embed_blocks]] and [[nav_items]]. The operator can test-install the pending app on their own store and use it before deciding — a pending app stays invisible and uninstallable for every other merchant until it is approved. Once approved it appears in every merchant's /apps marketplace automatically, with no config change or redeploy.

Make sure the URLs in your manifest are real before pushing — the starter template ships https://APP-HOST/… placeholders, and an app approved with those cannot complete an install.

The platform takes a 15% revenue share on app charges by default (config/apps.php → revenue_share). Your marketplace listing is built from your manifest identity, scopes, and the pricing/category fields you set at submission.

Hosting targets & environments

deploy records where your app runs in flashmandu.app.toml as hosting_target; push never takes a --target (hosting is a property of deployment, not of a submission). Which target works depends on the environment you are logged into:

Environment Use
local (127.0.0.1:8000) autohisab-business dev — tunnel + registration, no Docker needed
dev box autohisab-business dev, or --target self
production --target platform (Docker auto-installed), --target self, --target vercel
  • ›--target platform builds and pushes a Docker image on your machine. If Docker is missing the CLI installs it for you (OrbStack on macOS, etc.) — no manual setup. It is refused outside production because the tenant cluster only exists there — the CLI learns this from the platform, it has no host compiled in.
  • ›The /api/apps/dev/* endpoints (the dev loop's login + register) do not exist on production by design — they are a password-to-token surface for local/dev only. So dev is a local/dev workflow, not a bug.

Deploying a platform-hosted app

One command does the lot:

autohisab-business deploy --target platform

This ensures Docker is installed (OrbStack on macOS, etc.), logs in to the platform registry, builds your app's Dockerfile, pushes the image, and submits a versioned app for operator review. No manual Docker setup; no separate push.

autohisab-business push submits the current manifest for review without rebuilding — handy for metadata-only changes (scopes, URLs, pricing). Builds are local and language-agnostic, so any app with a Dockerfile works (Next.js, Laravel, …). Keep your image small: use a multi-stage Dockerfile with an Alpine final stage.

The platform registry is reachable over public HTTPS — you do not need to be on the platform's private network (WireGuard) to deploy. An operator working directly on the platform's app node can skip the public hop and push to the internal address instead: autohisab-business deploy --target platform --registry 10.10.0.2:5000.

Shared credential, for now. The registry uses a single shared username/password today — every publisher who can deploy holds the same credential (fetched automatically from /capabilities on production). This is tolerable because the platform pins an image digest at approval and an operator reviews that exact digest before it runs, so pushing to someone else's repository path cannot get arbitrary code executed. Per-publisher, scoped tokens arrive when third-party publishers do. Until then, treat the credential as you would any shared secret.

Platform-hosted apps

16 · Scale-to-zero infrastructure

A platform-hosted app runs on the tenant k3s cluster as a Deployment behind a Traefik IngressRoute at https://<slug>.apps.<storefront_domain>. The platform cannot afford to keep thousands of apps resident, so every app sleeps when idle and is woken on demand. How it sleeps and wakes is selected by the publisher's trust tier.

a. Two tiers, two mechanisms

TierruntimeClassNameSandboxSleep / wakeWaiting page?
untrusted (default) gvisor gVisor syscall interception Sablier scales the Deployment 0↔1; Traefik plugin wakes it Yes — cold start 1–3s behind a ghost-themed page
trusted (operator-granted) zeropod runc (via zeropod containerd shim) zeropod checkpoints to disk on TCP inactivity, restores on next connection (~tens to a few hundred ms) No — restore is transparent, no activator in the path

Source: Flashmandu\Apps\Models\AppPublisher (TIER_UNTRUSTED / TIER_TRUSTED, runtimeClassName()) and TenantClusterClient::scalesViaSablier() (true for every runtime that is not zeropod).

b. How a publisher's trust_tier is set

  • ›Default is untrusted. AppPublisher::forHandle() creates every new publisher with trust_tier = 'untrusted'. An unauthenticated CLI push derives the handle from the submitted email — there may be no User row at all — so trust cannot live on users.
  • ›Trusted is operator-granted. An operator flips a publisher's row to trusted after reviewing the app and the publisher. There is no self-service path.
  • ›Security implication. Trusted apps run on runc (no syscall sandbox) in exchange for fast checkpoint/restore. Untrusted apps run on gVisor, which intercepts syscalls, and pay a cold start behind Sablier. The trust tier makes exactly this trade.

c. Which tier am I in?

The tier belongs to your publisher account, not to an individual app. It is granted to the account, so every platform-hosted app you publish inherits the same tier — you cannot have one app trusted and another untrusted, and a submission is never promoted on its own. Every publisher starts untrusted, and only an operator can change it.

Open the Partner Center. Your tier is shown as a badge beside your publisher name — Trusted hosting or Untrusted hosting — with a plain-English line saying what it means for your merchants. That badge is the answer; it is stated once for the account because that is the only place the tier exists.
No API reports it, and the CLI cannot tell you. https://business-dev.autohisab.com/api/apps/me returns app_id, name, profile_id, scopes and subscribed_events — nothing about hosting. https://business-dev.autohisab.com/api/apps/capabilities tells you the environment and whether platform hosting exists at all, not which tier you are in. Read the badge, or ask the operator.
The badge describes your account, not every app listed under it. The Partner Center lists your in-process (provider_class) apps, which run inside the platform process and never touch the tenant cluster — no gVisor, no zeropod, no Sablier. The tier governs only platform-hosted apps (hosting_target = 'platform'). An Untrusted hosting badge does not mean your in-process app is being syscall-sandboxed.

You can also confirm it by behaviour. Leave a platform-hosted app idle past the Sablier session window (5 minutes by default) and open it in a browser:

  • ›A brief waiting page, then your app — you are untrusted. Sablier is scaling the Deployment back up.
  • ›Your app, immediately — you are trusted. zeropod restored the checkpoint with no activator in the request path.

Why a developer should care. On an untrusted account, a merchant's first request after an idle period waits one to three seconds on a plain waiting page before your UI appears. On a trusted account it does not — the restore is transparent and the merchant sees your app straight away. The wait applies to every platform-hosted app you own, so promotion fixes all of them at once.

d. What gets applied for an untrusted (Sablier) app

TenantClusterClient::applyIngressRoute() applies a per-app Sablier Middleware CRD alongside the IngressRoute, and applyDeployment() stamps two labels on the Deployment so Sablier discovers it:

# Deployment labels (TenantClusterClient::applyDeployment)
app: <name>
sablier.enable: "true"
sablier.group: <name>

# Traefik Middleware CRD (applySablierMiddleware)
spec:
  plugin:
    sablier:
      sablierUrl: http://sablier.platform-infra:10000   # config-driven
      group: <name>
      sessionDuration: 5m                                # config-driven
      dynamic:
        displayName: <submission.name>               # the app name from the submission
        theme: ghost                                     # clean minimal page (NOT the game-like default)
        showDetails: "false"

A trusted app gets none of this — layering Sablier on top of zeropod would fight the checkpoint. Each untrusted app gets its own Sablier group + Middleware so apps scale independently.

e. Configuration knobs

Key (config/apps.php)Env varDefaultMeaning
tenant_cluster.scale_to_zero.sablier_urlTENANT_CLUSTER_SABLIER_URLhttp://sablier.platform-infra:10000Sablier API the Traefik plugin talks to
tenant_cluster.scale_to_zero.session_durationTENANT_CLUSTER_SABLIER_SESSION_DURATION5mHow long a woken app stays up after the last request before scaling back to zero. Per-app (config-driven, not hardcoded) so a busy app can be given a longer window.
tenant_cluster.scale_to_zero.wake_timeoutTENANT_CLUSTER_WAKE_TIMEOUT10Seconds a synchronous platform caller (the OAuth token exchange) waits for a woken app before failing. Queued callers (webhooks) use the queue backoff instead.

f. Browser path vs machine path (the easy way to break this)

A browser hitting an embed iframe can be shown a waiting page — that is what Sablier is for. A machine caller cannot: a webhook POST or a token exchange that receives an HTML waiting page will fail (sometimes silently — a 200 containing HTML is not obviously an error).

  • ›Webhooks never traverse the waiting page. The platform wakes the app with TenantClusterClient::scaleDeployment() first, then delivers via the API server service proxy, retrying on the queue with backoff if the wake exceeds wake_timeout.
  • ›Trusted (zeropod) apps need none of this — restore is transparent to every caller, which is the real advantage of the tier beyond latency.

g. Where the waiting page is (and is not) handled

A cold request to an untrusted (Sablier) app can surface a Traefik-served "Starting <App>…" ghost page instead of the app's own UI. Four call sites decide what happens with that response, and they do not all handle it the same way:

Call siteCaller typeHow the waiting page is handled
StorefrontEmbedController
(~line 782)
Merchant's browser (storefront embed) A branded spinner overlays the iframe. It hides on the app-bridge READY postMessage, OR a 20 second hard safety net, OR the iframe's own load event + 1.5s. Whichever fires first reveals the frame's real content underneath — including the raw ghost page if the pod is still cold past 20s.
EmbedAppController + app-bridge-host.ts
(admin embed shell)
Merchant's browser (admin embed) Same pattern (branded overlay, hidden on app-bridge:ready), but the fallback timeout is BRIDGE_READY_TIMEOUT_MS = 8000 — a hardcoded 8 seconds, less than half the storefront's window and not sourced from tenant_cluster.scale_to_zero.wake_timeout. See finding below.
RemoteAppTokenExchange
(~line 53)
Machine (OAuth token exchange) Never touches the public edge for a platform-hosted app at all. Calls TenantDeploymentService::awaitReady() first and routes the actual exchange through the in-cluster service proxy — the JSON caller can never receive the HTML waiting page as a false 200.
TenantDeploymentService
(~line 36)
Marketplace verification job syncVerification() distinguishes "scaled to zero" (asleep — keeps verified_at) from "actually failing" (desiredReplicas >= 1 but readyReplicas = 0 — clears it) so a merchant's sleeping app does not silently vanish from the marketplace.
Finding — the admin embed path is less graceful than the storefront path. Both browser paths use the same strategy (overlay the frame, hide on a bridge-ready signal or a timeout) rather than a server-side "is this actually ready" check, so neither is immune to a slow cold start outliving its timeout. But the admin timeout is 8000ms (resources/js/app-bridge-host.ts, BRIDGE_READY_TIMEOUT_MS) against the storefront's effective 20000ms (StorefrontEmbedController::renderShell()) — under half the grace window, and not derived from any config value (the storefront number is also hardcoded, but larger). A cold pod that becomes ready between 8s and 20s after the request shows the merchant a covered spinner on the storefront, but shows the raw unbranded Sablier ghost page inside the admin once the 8s cap trips and app-bridge:placeholder uncovers the iframe. This was not fixed as part of this task — flagged here as a finding, not a change, per this task's scope (per-app resource caps + documentation only).

h. Teardown is reference-counted, not per-install

A platform-hosted app's Deployment/Service/Secret are shared across every profile that has it installed — uninstalling from one profile must never take the app offline for another. Jobs\TeardownPlatformHostingJob::handle() re-checks InstalledApp::withoutGlobalScopes()->where('app_id', …)->exists() and aborts the reclaim while any install of that app_id remains anywhere on the platform — this is the fix for the "one uninstall killed every profile's app" incident. The job also waits (bounded: 5 attempts × 60s) for the app.uninstalled webhook to be delivered before reclaiming, so the app has a chance to purge merchant data before its database access is cut — but the app's platform API token is revoked immediately regardless.

Covered by tests/Feature/PlatformHostedTeardownSharingTest.php and TeardownWaitsForUninstallWebhookTest.php.

i. Per-app CPU/memory caps

Every platform-hosted container gets a resources: block — requests and limits are config-driven (config/apps.php hosting key, env-overridable), not hardcoded, and default generous rather than tight: 250m/1000m CPU, 512Mi/1Gi memory. Requests are a scheduling hint (safe to raise); a memory limit is kernel-enforced — exceed it and the container is OOMKilled with no warning, so the defaults deliberately give a typical Next.js server-rendering container headroom rather than the 256Mi first sketched for this feature.

Rollback lever. Set any one of default_cpu_request, default_cpu_limit, default_memory_request, default_memory_limit to null / false and TenantClusterClient::applyDeployment() omits the resources: stanza entirely — containers schedule unbounded again, exactly as before this feature, with no code deploy required. This is the lever to pull if a tenant starts OOMKilling on a deploy.

j. Further reading

  • •Design spec: docs/superpowers/specs/2026-08-02-tenant-scale-to-zero-design.md
  • •Execution log: docs/superpowers/plans/2026-08-02-EXECUTION-STATUS.md
  • •Capability ladder Task 3.2 status correction: docs/superpowers/plans/2026-08-14-app-platform-capability-ladder.md

Platform-hosted

16a · Secrets & the hosted runtime doctrine

API keys belong in secrets, never in settings. Settings are readable by the platform and shown to the merchant in the admin UI. Credentials must never be either — so they live in a separate, write-only store that is injected into your container as environment variables.

autohisab-business config set KLAVIYO_API_KEY=pk_live_xxx
autohisab-business config list        # KEY NAMES ONLY — never values
autohisab-business config unset KLAVIYO_API_KEY
There is no read-back. No endpoint, no command, no admin screen returns a secret value — not even to you. Values are encrypted at rest and appear only as environment variables inside your own container. If you lose one, set it again.

Rules

  • Key format ^[A-Z][A-Z0-9_]*$; value up to 8 KB; 100 keys per app.
  • Reserved and rejected: FLASHMANDU_*, DATABASE_URL, PORT — the platform owns those.
  • Platform-injected variables always win over a secret of the same name.
  • Secrets are scoped per app, matching the deployment model (one deployment per app id).
  • Setting a secret does not restart anything — a running container keeps its start-time environment. The response returns pending_deploy: true; it takes effect on your next deploy.
  • Self-hosted apps get a clear self_hosted error rather than silent acceptance — your environment is yours to manage.

Runtime doctrine

Your container is an HTTP server. That is the whole contract:

  • Serve HTTP on $PORT and answer a health check; start in under 30 seconds.
  • Answer webhooks in under 10 seconds — accept with 202 and continue asynchronously in-process.
  • No cron inside the container. Declare [[schedules]] (§6a) and the platform calls you.
  • The provisioned database is your state store.
  • Logs go to stdout and stream through autohisab-business logs.

Platform-hosted apps

17 · CLI command reference

The @flashmandu/autohisab-business-cli package (autohisab-business) drives the whole developer loop. Source of truth: the CLI repo at flashmandu-app-cli (src/commands/*.ts). The CLI compiles in no platform host — it learns everything from the platform at runtime. Every command accepts the global --base-url <url> flag, which overrides the dev-box base URL for that invocation only.

a. How the CLI learns the platform host & registry

On login you give the CLI a dev-box base URL; that is stored in ~/.flashmandu/credentials.toml. Running autohisab-business login without flags prompts an environment picker — business.autohisab.com (production), business-dev.autohisab.com (development), or a custom http(s) URL for local development; --base-url <url> skips the prompt. For deploy --target platform the CLI calls GET /api/apps/capabilities — the only place it learns the tenant registry address and whether platform hosting is available:

GET /api/apps/capabilities → {
  environment: "production" | "local" | "development",
  tenant_registry: string | null,       // public push address; null outside production
  platform_hosting: boolean,            // true only in production
  registry_push_user: string | null,    // shared registry credential
  registry_push_password: string | null,
}

platform_hosting: false (or a null registry) makes the CLI refuse --target platform with a pointer at dev — the tenant cluster only exists in production. The /api/apps/dev/* login/register routes used by dev are not served on production by design (they are a password-to-token surface), so dev is a local/dev-only workflow.

b. Commands & flags

CommandFlags / argsWhat it does
login -b, --base-url <url>
-p, --profile <name> (default default)
--token <token> (skip the credential prompt)
POSTs username/password to /api/apps/dev/login, verifies the token against /api/apps/me, stores it in ~/.flashmandu/credentials.toml (mode 0600). Without --base-url, prompts an environment picker: production (business.autohisab.com), development (business-dev.autohisab.com), or a custom http(s) URL for local development.
logout [profile] [profile] (default: the active one) Removes a profile from the credential store.
whoami — Verifies the stored token against /api/apps/me; prints app id, name, profile, granted scopes, subscribed events.
config config set base_url <url>
config get base_url
config list
Manages CLI configuration. set/get the active profile's base_url (the platform/dev-box host, not your app URL); list shows all profiles and marks the active one.
init [dir] --template <next-js|php-remote>
--app-slug <slug> (leaf only — no namespace)
--app-id <id> (alias; leaf after any / is used)
--name <name>
--base-url <url>
Scaffolds a template and writes flashmandu.app.toml with sentinel https://APP-HOST/… URLs. You declare only the leaf slug; the host namespaces it under your publisher handle.
dev -p, --port <n> (default 3000)
--app-command <cmd> (auto-detected from package.json/composer.json)
--no-app-server
-d, --dir <path>
--tunnel <cloudflared|ngrok>
--tunnel-url <url> (bring-your-own; implies --no-app-server)
--no-open
--sandbox-path <path> (default /apps/{appId}/embed)
The no-push local loop. Starts your app, opens a public HTTPS tunnel, registers/refreshes the sandbox against /api/apps/dev/register, writes .flashmandu/sandbox-secrets.<app>.json, and opens the embed preview. Re-run if the tunnel URL changes.
push -d, --dir <path>
--developer-name <name>
--developer-email <email>
--source-url <url>
--description <text>
--version <semver>
--no-bump
--yes
-t, --target <target> (rejected — see note)
Marketplace publish only. Submits the current manifest for operator review; never builds or touches a host. Auto-bumps the patch unless --version/--no-bump. --target is accepted only to reject it with a pointer at deploy — hosting is a property of deployment.
doctor -d, --dir <path> Static, offline checks over your source tree — no network, no build. Every rule maps to a real incident that shipped broken; the sixteen are listed below. Run it before push.
section dev -d, --dir <path>
--once
Authoring loop for storefront sections. Watches the blueprint files named in [[storefront_sections]] and re-pushes on every save; --once pushes and exits. Each push replaces the declared set, so a section removed from the manifest leaves the palette. JSON errors report the file and position.
deploy -d, --dir <path>
-t, --target <vercel|self|platform>
--base-url <url> (required for self)
--registry <registry> (env FLASHMANDU_TENANT_REGISTRY)
--platform-host <host>
Records a real production host. For --target platform: requires login, probes /capabilities, ensures Docker (OrbStack etc.), docker login with the advertised credential, builds --platform linux/amd64, pushes, resolves the digest, and submits a versioned app for review. For vercel: shells out to the Vercel CLI. For self: you supply the URL.
logs -d, --dir <path>
--app-slug <slug> / --app-id <id> (alias)
--platform <publisher/slug> (pod logs)
--tail
--interval <s> (default 5)
Default: recent webhook deliveries from /api/apps/dev/deliveries, authenticated with the app's sandbox api_token when present. With --platform: fetches platform-hosted pod logs for <publisher>/<slug> via the platform.
hooks --category <name>
--type <event|filter>
Lists platform hooks from the public /api/apps/hooks endpoint (no auth).
ps / stop stop [pid]
stop --all
stop --port <n>
stop -f / --force (SIGKILL)
List running dev loops, their ports and tunnels; stop one by pid, everything, or whatever holds a port.
hooks deliveries hooks deliveries Recent webhook attempts — event, status code, attempt number, latency. Your first stop when a delivery seems missing (§6b).
hooks replay hooks replay <delivery-id> Re-dispatch one delivery after fixing your handler — no need to re-trigger the business event.
config config set KEY=value
config list
config unset KEY
Hosted-app secrets (§16a). Write-only — list shows key names, never values. Applies on your next deploy.
Declared in the manifest, synced by the CLI. [webhooks] (events + URL) and [[schedules]] blocks in flashmandu.app.toml are pushed to the platform on dev, push and deploy. The manifest is the source of truth — edit it and re-sync rather than mutating subscriptions out of band.

c. deploy --target platform preconditions

  1. 1Logged in. The CLI needs an active profile (autohisab-business login) to call the platform at all.
  2. 2Platform advertises hosting. /capabilities must return platform_hosting: true and a non-null tenant_registry with a push credential. This is true only in production — the CLI refuses elsewhere.
  3. 3Docker available. deploy calls ensureDocker(), which installs OrbStack (macOS) / the platform-equivalent if missing. A working Dockerfile in the app root is required.
  4. 4developer_email set. The image path is <registry>/<handle>/<slug>. handle is derived from your developer_email (email local-part, lowercased, non-alphanumerics to dashes) only when that name is free. If it collides with an existing publisher, the platform mints a disambiguated handle instead (<local-part>-<yourUserId>, falling back further from there) — so do not assume your handle equals your email local-part. Don't derive it yourself: the canonical id is printed after every successful push/deploy (§14), and every app you own is listed under its real <handle>/<slug> id on the partner dashboard (/apps/partners).

Platform host resolution for the derived embed/host URLs: --platform-host flag → FLASHMANDU_BASE_URL_OVERRIDE env → the logged-in profile's base_url. The build targets linux/amd64 because the tenant cluster is x86_64 — an Apple-Silicon arm64 image would fail to schedule.

Doctor rules

Every rule below exists because something shipped broken in a real app. They run offline over your source tree — no network, no build — so doctor is cheap enough to run on every save. error fails the command; warn reports and passes.

Rule Severity Fires when
no-fm-override error App CSS redefines a .fm-* selector. The kit owns that namespace; a fork silently diverged from its breakpoint and fought it in the cascade.
frame-headers error next.config.js missing frame-ancestors, X-Frame-Options, or transpilePackages. Every shipped app rendered a blank embed until this was hand-fixed.
no-sidebar-links error A runtime setSidebarLinks() call. It shrinks the host sidebar; manifest nav_items is the only supported path.
no-pinned-dark error Hardcoded data-fm-theme="dark", which permanently pins dark mode instead of fixing the fallback.
no-client-prisma error A 'use client' file transitively imports Prisma, dragging node:fs into the browser bundle.
webhook-raw-body error A webhook receiver calls req.json() without reading the raw body first, so the signature can never verify. GET/HEAD-only routes are exempt.
kit-current error The vendored kit dist is behind the pinned version.
kit-css-single-entry error Importing tokens.css/components.css directly instead of index.css silently drops the bundled typeface.
kit-buttons-only error Off-kit <button> elements instead of the fm-btn family — 22 shipped across three apps.
no-native-dialogs error window.confirm()/alert() inside the embed. An OS dialog breaks the native-app illusion.
no-theme-pin error data-fm-theme pinned to any value, which blocks the merchant's theme.
route-states warn No loading.tsx/error.tsx/not-found.tsx — navigation feels dead while the route resolves.
app-css-budget warn App CSS has grown into a parallel design system on top of the kit. Warns; does not fail.
typography-scale error Raw font-size declarations instead of the --fm-text-* scale — the actual cause of "the fonts look wrong" reports.
page-chrome-wired error The bridge-ui page-chrome hook is never wired, so titles, breadcrumbs and the Save/Discard swap never reach the host command bar.
section-blueprint-valid error A declared blueprint_file is missing or malformed, or a section's source and payload disagree. Node types are out of scope — the host owns that catalogue.
Verify against the CLI repo. Flags above are read from flashmandu-app-cli/src/commands/*.ts. If you are reading a printed copy, re-check those files — the CLI moves faster than this page.

Platform-hosted apps

18 · App name & icon

Both come from your submission (the manifest + what the CLI posts at push/deploy) and are persisted on the AppSubmission row the operator reviews.

a. Setting the name

Set name in flashmandu.app.toml (or pass --name to init). The CLI submits it; the platform stores it on AppSubmission::name. It surfaces in:

  • ›The marketplace listing (AppSubmission::marketplacePayload() returns name falling back to the app id).
  • ›The embed shell title merchants see in the admin chrome.
  • ›The Sablier waiting page displayName for untrusted apps (the clean ghost theme renders it). Trusted apps do not show a waiting page.
  • ›The page-builder block picker, grouped under the owning app.
# flashmandu.app.toml
slug = "interactive-map"
name = "Interactive Map"        # ← merchant-facing display name

b. Setting the icon

The app-level icon is a Heroicon name — the set Flux bundles and renders as <flux:icon.{name}>, which is what every admin surface on this platform uses. Set it as icon on the submission; it defaults to squares-2x2 when blank.

  • ›Use the kebab-case Heroicon name with no prefix — e.g. map, calendar-days, calculator, squares-2x2. Not Tabler: 322 names resolve, and a Tabler-only name such as calendar-event resolves to nothing. Same namespace as [[nav_items]] icon (§4).
  • ›It surfaces in the marketplace listing and (at the block level) in the page-builder picker. Each embed block also carries its own icon on the EmbedBlockDefinition (see §19), so a multi-block app can give each block its own glyph.
What remains. The exact CLI flag / manifest key that sets the app-level icon (vs the per-block [[embed_blocks]] icon) is not exposed in the platform-side reader — verify against the CLI repo at flashmandu-app-cli/src/services/manifest.ts and the submission payload shape.

Platform-hosted apps

19 · Embed blocks — deep dive

Section 2e is the quickstart. This is the complete contract: the SDK DTO, how block_origin/block_path are derived for platform-hosted apps, the two dynamic-options payload shapes, the three gates, and how your options endpoint must authenticate. Source of truth: Flashmandu\Apps\EmbedBlockCatalog, Flashmandu\AppSdk\Storefront\EmbedBlockDefinition, and Flashmandu\AppSdk\Settings\SettingField (SDK pinned in vendor/flashmandu/app-sdk).

a. The EmbedBlockDefinition DTO

use Flashmandu\AppSdk\Scope;
use Flashmandu\AppSdk\Settings\SettingField;
use Flashmandu\AppSdk\Storefront\EmbedBlockDefinition;

new EmbedBlockDefinition(
    type:          'interactive-map',        // stable machine key, unique within the app
    name:          'Interactive Map',         // merchant-facing label in the picker/inspector
    description:   'Embeddable storefront map',
    category:      'Maps',                    // picker grouping
    icon:          'map',                     // Heroicon key (see §18b)
    block_origin:  'https://...',             // pinned at install; see b. below
    block_path:    '/embed/interactive-map',  // path under origin; host assembles full src
    settings:      [ /* SettingField[] — see c. */ ],
    capabilities:  ['identity', 'resize'],    // subset of ALLOWED_CAPABILITIES
    scopes:        [Scope::Identity],         // enforced centrally; approved at OAuth install
    schema_version: 1,                        // bump to run onSettingsMigrate()
    replaces:      null,                      // previous `type` this supersedes (rename path)
    hosting_tier:  'remote',                  // 'trusted' | 'remote'
);

Allowed capabilities: identity, resize, navigate, error. Allowed hosting tiers: trusted, remote. The block renders on the storefront as a sandboxed scripts-only iframe; it talks to the host only through the locked-down storefront App Bridge (postMessage).

b. block_origin / block_path for platform-hosted apps

For a platform-hosted app the origin is derived, never caller-supplied. The platform builds it from the app id slug and the configured storefront domain (AppSubmission → TenantClusterClient::hostnameFor()):

block_origin = https://<slug>.apps.<storefront_domain>
# e.g. https://interactive-map.apps.your-domain

block_path  = declared in your manifest (e.g. "/embed/interactive-map")
full src    = {block_origin}{block_path}?session_token=…   # host assembles this

In the local dev loop the CLI substitutes the live tunnel URL as block_origin at registration time, so your TOML only declares block_path.

c. Settings: static options vs dynamic dynamic_options

SettingField supports six types (text|number|boolean|select|color|url). A select either ships a static list or fetches live options from your app server-side.

ShapeWhereNotes
Static
options
Inline ['value' => 'Label'] pairsFor fixed enumerations (tiers, modes). Never calls your app.
Dynamic (canonical)
dynamic_options: {source, path}
SDK DTO / nested formsource is 'app'; path is fetched server-side from block_origin. Nested wins.
Dynamic (flat, accepted)
dynamic_options_path
Some CLI/manifest emitsThe reader ALSO accepts the flat form — a reader that only knew the nested form silently listed no options. It is normalised into the nested shape on read.
# flashmandu.app.toml — the flat form the CLI persists, and the nested
# form the SDK canonicalises to. EmbedBlockCatalog accepts BOTH.
[[embed_blocks.settings]]
name = "map_id"
label = "Map"
type = "select"
required = true
dynamic_options_source = "app"
dynamic_options_path   = "/api/blocks/interactive-map/options/map_id"

d. Three gates (all must pass)

  1. 1Plan entitlement. The profile's plan must grant the app_embed_blocks capability. Folded into the cached catalog so the storefront never re-runs EAR per block.
  2. 2Merchant master toggle. installed_apps.embed_blocks_enabled — OFF per app by default. The merchant turns it on under Installed Apps.
  3. 3Install-time scope grant. Every scope in EmbedBlockDefinition::scopes must be in the row's grantedScopeValues() (same check as ScopeGuard).

A miss is silent: an under-scoped, disabled, or unentitled block simply does not appear. The picker does not explain which gate failed — check them in that order.

e. Options endpoint authentication — App Bridge session token

When the merchant opens your block in the page-builder inspector, the platform calls your dynamic_options_path server-side and renders the response as the dropdown. The request carries:

GET {block_origin}{dynamic_options_path} HTTP/1.1
Authorization: Bearer <app-verifiable token>
  • →Token format. This is the App Bridge session token (HMAC, keyed by the installation's webhook_secret — the platform's AppEmbedTokenService), not the admin v1 token. Your app verifies it offline (no callback to the host). Sending the admin token instead makes every options endpoint answer 404 no_profile because the app cannot verify it.
  • →Claims. profile_id, surface="admin", exp (120s TTL). Unlike the pseudonymous storefront hop, the admin surface names the profile — the merchant is configuring their own block and you must scope the list to them.
  • →Response shape. Either [{"{"}"value","label"{"}"}, …] or {"{"}"options":[…]{"}"}. Return [] (200) when the merchant has nothing published yet. Error objects ({"{"}"error":…{"}"}) are discarded, never rendered as choices.
  • →Caching. 60s positive, 15s negative (a briefly-down origin recovers fast; a broken one stops costing a 4s timeout per inspector open). Keyed per profile under apps:embed:{profileId}:opts:{sha1(url)} so revalidate can drop it.

f. Worked example — interactive-map options route

The reference implementation lives at /Users/shirish/nextjs/interactive-map/app/api/blocks/interactive-map/options/map_id/route.ts. It resolves the profile from the bearer token (with a ?profile_id= dev fallback), queries its own database for the merchant's maps, and returns the normalized list:

// 1) App Bridge session token (Authorization Bearer or ?session_token=).
const sessionToken = url.searchParams.get('session_token') ?? bearer;
const result = verifyStorefrontToken(sessionToken, APP_SECRET);  // HMAC, offline
if (result.ok) profileId = Number(result.payload.profile_id);

// 2) Dev fallback — ?profile_id=. Never trusted in production.

// 3) Return [{value, label}] for this profile's maps, scoped to them.
return NextResponse.json(maps.map((m) => ({ value: String(m.id), label: m.name })));

g. Revalidate after your data changes

When your app's data changes (a map created, areas edited, a record deleted), POST the revalidate hook so the platform drops every cache derived from your app for the installing profile — both the entitlement-resolved catalog and the dynamic-options entries, plus the cached CMS settings-panel HTML that bakes in the picker choices.

POST https://business-dev.autohisab.com/api/apps/embed/revalidate
Authorization: Bearer <your api_token>
Content-Type: application/json

{"profile_id": 7}   # optional; defaults to the installing profile from the token

Standard remote-app middleware: the Bearer api_token resolves the InstalledApp row, and the row alone decides which profile is purged — a caller cannot name another tenant's profile (a named id is accepted only when an enabled install of the same app exists for that profile). A platform-hosted app runs one pod for every installing profile with a single shared credential, so the pod may name the profile it just mutated.

h. Hydration & failure modes

  • ›Remote vs local source of truth. Local (PHP-native) apps declare blocks on their manifest's Storefront aggregate. Remote apps have no manifest on the host; their block declarations are persisted on installed_apps.extensions at OAuth install. Entries lacking block_origin/block_path are slot Extension contributions and are ignored here.
  • ›A malformed block never fatals. Hydration is best-effort; a shifted payload shape logs a throttled warning (apps:embed:hydrate-error:{app}, once per hour per app) and skips the entry. One broken block cannot take down every merchant's page builder.
  • ›The [[embed_blocks]] quickstart in §2e is the shortest path; this section is the contract. Read both.

Reference app

20 · Showcase — the reference app

Every capability on this page runs somewhere in Showcase. It is one remote Next.js app that exercises the whole Phase 1 remote surface — chrome, pickers and modals, session tokens and the direct API, keyset lists, webhooks, schedules, ops and connectExternal — with a "view source" panel on each screen showing the code that drives it. The See it running links throughout this page point into it.

Three things at once

  • ›Living documentation. One screen per capability, each next to the source that produces it. Prose here can drift; a screen that stops working is a failing build.
  • ›The reference implementation. What autohisab-business init graduates toward — copy its patterns rather than inventing them. The sync engine on /sync is §8b implemented, not paraphrased.
  • ›A certification vehicle. Its suite runs against the real platform, so a host change that breaks a remote-app surface fails here before it reaches a third party. No capability is ever demonstrated with mock data — if it cannot run live, its row stays red on the matrix.

Where it lives

Source: /Users/shirish/nextjs/showcase — a Next.js app scaffolded with the CLI's next-js template, with its own SQLite/Prisma store for the little state it keeps (sync cursors, a copy of the event log, external connections). Platform reads on the server use the bearer api_token; browser calls go through the bridge session token. Registered on this platform as owner-1/showcase, embedded at https://business-dev.autohisab.com/apps/owner-1/showcase/embed.

Not merchant-installable. Showcase is never published to the marketplace. It registers through the developer dev flow (POST /api/apps/dev/register, local/dev/staging only) against a sandbox profile, so no merchant can install it and it holds no production data. It also carries no real third-party integration — the connectExternal demo talks to a fake OAuth provider the app serves itself, so the whole popup flow runs with zero external accounts.

Run it locally

# 1) start the app yourself — --tunnel-url implies --no-app-server
cd /Users/shirish/nextjs/showcase
npm install && npm run dev -- --port 3100

# 2) in a second shell: register it and open the embed
autohisab-business dev --tunnel-url http://localhost:3100

# 3) inspect what the platform delivered to it
autohisab-business hooks deliveries -n 25
autohisab-business config list

dev writes .flashmandu/sandbox-secrets.<publisher>-showcase.json (mode 0600, gitignored) carrying the api_token and webhook_secret; the app reads them as FLASHMANDU_API_TOKEN and FLASHMANDU_WEBHOOK_SECRET. A loopback URL needs apps.embed.allow_loopback and, for the webhook endpoint, apps.webhooks.allow_insecure_urls — otherwise front it with a tunnel and drop the --tunnel-url flag.

The screens

RouteScreenDocumented in
/Overview — the capability matrixthis section
/dataData Browser — a tab per domain, IndexTable + FilterBar + skeletons, hydration detail panes§2c, §8b
/syncSync — install walk → live webhooks → scheduled reconcile, with cursors, counts and schedule health§6a, §8b
/eventsEvent Inspector — verifier verdicts, skew/dedupe/sequence, thin-payload hydrate§6, §9
/playgrounds/…chrome · toast · modal · picker · ledger · writes · api-console§2b, §4, §4a, §8, §8a
/connectionsConnections — the full connectExternal popup flow, including the denial path§7a
/opsOps — deliveries and replay, schedule health, hosted secrets, the CLI commands behind each§6b, §16a, §17
/settingsSettings — the dirty-state Save/Discard chrome demo, plus where app config really lives§2b, §16a
/kitKit gallery — every UI-kit component with its states§5

The links open the host embed shell, not the app's own origin: the shell authenticates you, frames the app and forwards the in-app route as ?path=. They only resolve on a box where the app is registered and running — this page links the same routes regardless, so the map above stays true even when the app is stopped.

See it running in the Showcase app: Overview /