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.
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 →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 it | You, or the platform's app cluster. | The platform, in its own process. |
| Language | Anything that serves HTTPS. | PHP 8.2+, a Composer package. |
| UI toolkit | Your framework + the App Bridge UI kit (§5). | Host layout + ui:: components + Flux (§3b). |
| Data access | Scoped GraphQL + the bridge proxy allowlist. | Platform ports, in-process, scope-checked (§3d). |
| Install | OAuth PKCE, per merchant. | Operator registers the provider class; merchant installs. |
| Distribution | Open marketplace, after review. | Vetted partners and first-party only. |
| Best for | SaaS products, AI integrations, anything you already run. | Deep integrations that must feel native and read host data hot. |
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)
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.
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:
- 1Starts your app server (preflights the port).
- 2Opens a cloudflared quick tunnel — a public HTTPS URL. Required: the platform's SSRF guard refuses webhook URLs that resolve to private/loopback IPs.
- 3Registers/refreshes your app via
POST https://business-dev.autohisab.com/api/apps/dev/register, substituting the tunnel URL into your manifest'sauthorize_url,token_url,webhook_url. - 4Writes sandbox secrets to
.flashmandu/sandbox-secrets.<app>.json(mode 0600, gitignored):api_token+webhook_secret. - 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 everylogin. - •
.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";
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>
);
}
'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-view | teal | Open / view publicly — leaves the admin surface |
| .btn-action-edit | blue | Edit — reversible mutation |
| .btn-action-delete | red | Destructive — 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.
| Need | Use |
|---|---|
| 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.
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.
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".
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.
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
- Delete the
createAppShell/AppTabsmodule. Tabs are not app chrome any more: the sidebar[[nav_items]]and the breadcrumb trail are the navigation. - Delete every in-page
<PageHeader>/ desktop<h1>. After the migration the only<h1>left in the app should be<MobilePageHeader>'s, which islg:hidden. - Replace
useBridgeTitle(…)with oneusePageChrome({ title, crumbs, actions, dirty })per route. - 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. - Feed
dirtyfrom a real diff against a captured baseline, and re-baseline after a successful save — otherwise the host's dirty bar never clears. - Swap
window.confirmfor the host modal andwindow.location.hreffor 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 / Discard | Host command bar, via usePageChrome({ actions, dirty }) |
| New / Add (list → create) | In the page, top-right of the list |
| Preview, and any other navigation | In 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'linkComponentrejectsnext/link. The slot typeshref: string; Next types itstring | UrlObject. WrapLinkin a three-line adapter that narrowshreftostring.createMockHost()replacesglobalThis.windowwith a bareEventTarget, which react-dom cannot survive (x instanceof window.HTMLIFrameElementthrows). To render components against it, demote the fake to being only the app's parent: keep jsdom's window as the app window, pointwindow.parentat the fake, givewindow.topa distinct object so the embedded check passes, and re-emit host→iframe messages onto the jsdom window.FilterBarhas 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} />}
/>
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.
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:
- 1Registered. Run
autohisab-business dev— the CLI pushes[[embed_blocks]]onto your install record. - 2Merchant-enabled. Embed blocks are OFF per app by default. The merchant turns them on under Installed Apps.
- 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
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 = truegives 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_defaultis the dark-mode counterpart ofdefault. Only meaningful alongsidethemed. - →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.
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. |
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.
@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:
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 · Primitives | Text, Icon, Button, Input, Field, Card, Badge, Popover | @flashmandu/app-bridge-ui |
| L3 · Composition | Page, Tabs, Card.Section, IndexTable, FilterBar, EmptyState, the skeletons | @flashmandu/app-bridge-ui |
| L4 · Patterns | Whole screens: index → detail, settings, empty & error states, destructive action, long-running sync, third-party connection | This page + the Showcase app. No code artifact. |
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 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
EmptyStateand 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 ownemptyStatetoIndexTableusing 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
warningtoast 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-deleteis red and means destructive); do not re-map them. - ›Uninstall is not a delete button.
onUninstallmust 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 action | actions[].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
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.
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-pathis 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 (Fluxsize="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-paddrives 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-mutedwith 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-smmuted, 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-mutedrow 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. Thetheadqualifier is load-bearing: an accessible table gives its first column<th scope="row">, and a barethrule 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) andTable(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-tabsis aninline-flexwith 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-primaryand 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--blockstretches 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.
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.
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.
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 withphp artisan route:list --name=loyalty. - !The app must be installed and enabled for the current profile. A registered-but-not-installed app contributes nothing.
- ·
requiredScopesgates per item, not per app: an admin-only screen can be hidden while the app's main entry stays visible. - ·
iconis a host icon name (star,building-office-2,puzzle-piece…). An unknown name renders nothing rather than failing. - ·
childrenis 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 declarechildren: 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 ownchildrenare ignored). Every level is gated by its ownrequiredScopesand 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.
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 plainarray<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 benull(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.
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 |
|---|---|---|
| CatalogPort | forIds(), search() | read:catalog |
| PartyPort | forIds(), search(), ensureParties() | read:customers / write:customers |
| LocationPort | forIds(), ofKind(), ensureLocations() | read:locations / write:locations |
| OrdersPort | forLocationRefs() | read:orders |
| LedgerPort | post(), reverse(), postingsForReferences() | read:ledger / write:ledger |
| DiscountPort | forIds(), forCodes(), quote(), evaluate(), rulesForSource(), upsertRule(), deleteRule() | read:discounts · write:discounts |
| PricingPort | quoteLines() | read:pricing |
| TaxPort | quote(), quoteLines(), taxes() | read:tax |
| SequencePort | reserve(), reserveBlock() | write:sequences |
| PaymentPort | begin(), forReferences(), refund(), recordTenders(), startGatewayPayment(), refundTender(), chargeStoredInstrument(), gateways() | read:payments / write:payments |
| MessagingPort | sendTemplatedEmail(), sendTemplatedWhatsapp() | send:messages |
| ModuleContentPort | entries() | read:content |
| SettingsPort | current(), forIds() | read:settings |
| NotificationPort | ensureType(), notifyStaff(), notifyUser() | send:notifications |
| BroadcastPort | toProfile() | 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 nofind($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
ScopeDeniedExceptionand writes an audit row the merchant can see. Check$ctx->hasScope()first on optional features rather than catching it.
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'],
],
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).
| Method | Path | Scope | Returns |
|---|---|---|---|
| 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
<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.
| Type | Direction | Payload | Description |
|---|---|---|---|
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.
/playgrounds/picker
Modal playground
/playgrounds/modal
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-btnButtons. Modifier classes for .fm-btn--primary, .fm-btn--ghost, .fm-btn--danger.
.fm-cardSurface container with token-driven border, radius, shadow.
.fm-inputText inputs, selects, textareas. Pairs with .fm-label.
.fm-calloutInfo/success/warning/error banners. Variant via data-fm-variant.
.fm-tableBordered data table; honors --fm-* tokens for stripes/hover.
.fm-badgeInline 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>
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"}}
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 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.
autohisab-business hooks deliveries if ticks stop arriving.
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.
202 and continue processing
asynchronously rather than doing the work inline — a slow handler burns your retry budget.
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>"
}
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.
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
connectExternal()opens a popup at your URL (the app frame is sandboxed with popups allowed).- You redirect the merchant to Facebook/Google/etc. and they consent.
- The provider redirects back to your callback, on your origin.
- Your backend exchanges the code and stores the tokens in your own database (or, for app-level credentials, in platform secrets — see §16a).
- Your callback page posts completion to its opener; the promise resolves. A popup closed by the merchant rejects instead.
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 yourapi_token, behind the sameremote-appmiddleware 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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
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
| Field | Scope | Description |
|---|---|---|
platformSettings |
read:settings | Display settings for the profile in scope. |
Viewer & Identity
| Field | Scope | Description |
|---|---|---|
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
| Mutation | Scope | Description |
|---|---|---|
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
| Mutation | Scope | Description |
|---|---|---|
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
| Mutation | Scope | Description |
|---|---|---|
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
| Mutation | Scope | Description |
|---|---|---|
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
| Mutation | Scope | Description |
|---|---|---|
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
| Mutation | Scope | Description |
|---|---|---|
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
| Mutation | Scope | Description |
|---|---|---|
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
| Mutation | Scope | Description |
|---|---|---|
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
| Mutation | Scope | Description |
|---|---|---|
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
| Mutation | Scope | Description |
|---|---|---|
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.
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.
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's content: drop what you cached for `path` and for the entry'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'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'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'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'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 "Settle on" 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: "<app id>:<section>", 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. |
$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)
| Scope | Enum case | Status |
|---|---|---|
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.
| Scope | Enum case | Notes |
|---|---|---|
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.mdin 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.
| Target | Surface | Enum 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 aremoteUrlthat 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 target | Kind | Rendered 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. |
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.
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
<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.
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
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.
| Surface | Allowed 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
/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"]
- ›
pathis 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-qualifiedremoteUrlis used as-is. - ›
modeis 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
targetis 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. - ›
handleis 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
| type | Host control | Notes |
|---|---|---|
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 platformbuilds 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 (thedevloop's login + register) do not exist on production by design — they are a password-to-token surface for local/dev only. Sodevis 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.
Reference — shared
15 · Reference Links
flashmandu/app-sdk (Packagist)
The PHP SDK — AppProvider, AppManifest, HookRegistrar, ExtensionTarget, SettingField, AppTester.
@flashmandu/autohisab-business-cli (npm)
The developer CLI — login, init, dev, deploy, push, hooks, logs.
@flashmandu/app-bridge (npm)
The iframe SDK — createAppBridge, ready context, proxied reads, toast, navigate.
@flashmandu/app-bridge-ui (npm)
CSS tokens + component classes for embedded apps. Dark mode automatic.
CLI command reference (§17)
Every command, flag and starter template, on this page.
Hook & scope catalog (§9, §10)
Rendered from the live catalog at request time — the authoritative list.
The SDK and CLI source repositories are not public. Everything you need to build against them is published to Packagist and npm above, and documented on this page.
https://business-dev.autohisab.com/api/apps/hooks lists every hook (no auth);
https://business-dev.autohisab.com/api/apps/me returns your app identity (Bearer auth);
https://business-dev.autohisab.com/api/apps/graphql is the data API (Bearer auth, scope-enforced).
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
| Tier | runtimeClassName | Sandbox | Sleep / wake | Waiting 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 withtrust_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 onusers. - ›Trusted is operator-granted. An operator flips a publisher's row to
trustedafter 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.
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.
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 var | Default | Meaning |
|---|---|---|---|
| tenant_cluster.scale_to_zero.sablier_url | TENANT_CLUSTER_SABLIER_URL | http://sablier.platform-infra:10000 | Sablier API the Traefik plugin talks to |
| tenant_cluster.scale_to_zero.session_duration | TENANT_CLUSTER_SABLIER_SESSION_DURATION | 5m | How 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_timeout | TENANT_CLUSTER_WAKE_TIMEOUT | 10 | Seconds 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 exceedswake_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 site | Caller type | How 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. |
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
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_hostederror 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
$PORTand answer a health check; start in under 30 seconds. - Answer webhooks in under 10 seconds — accept with
202and 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
| Command | Flags / args | What 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_urlconfig 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 --allstop --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=valueconfig listconfig unset KEY
|
Hosted-app secrets (§16a). Write-only — list shows key names, never values. Applies on your next deploy. |
[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
- 1Logged in. The CLI needs an active profile (
autohisab-business login) to call the platform at all. - 2Platform advertises hosting.
/capabilitiesmust returnplatform_hosting: trueand a non-nulltenant_registrywith a push credential. This is true only in production — the CLI refuses elsewhere. - 3Docker available.
deploycallsensureDocker(), which installs OrbStack (macOS) / the platform-equivalent if missing. A workingDockerfilein the app root is required. - 4developer_email set. The image path is
<registry>/<handle>/<slug>.handleis derived from yourdeveloper_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 successfulpush/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. |
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()returnsnamefalling back to the app id). - ›The embed shell title merchants see in the admin chrome.
- ›The Sablier waiting page
displayNamefor untrusted apps (the cleanghosttheme 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 ascalendar-eventresolves 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
iconon theEmbedBlockDefinition(see §19), so a multi-block app can give each block its own glyph.
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.
| Shape | Where | Notes |
|---|---|---|
Staticoptions | Inline ['value' => 'Label'] pairs | For fixed enumerations (tiers, modes). Never calls your app. |
Dynamic (canonical)dynamic_options: {source, path} | SDK DTO / nested form | source is 'app'; path is fetched server-side from block_origin. Nested wins. |
Dynamic (flat, accepted)dynamic_options_path | Some CLI/manifest emits | The 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)
- 1Plan entitlement. The profile's plan must grant the
app_embed_blockscapability. Folded into the cached catalog so the storefront never re-runs EAR per block. - 2Merchant master toggle.
installed_apps.embed_blocks_enabled— OFF per app by default. The merchant turns it on under Installed Apps. - 3Install-time scope grant. Every scope in
EmbedBlockDefinition::scopesmust be in the row'sgrantedScopeValues()(same check asScopeGuard).
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'sAppEmbedTokenService), not the admin v1 token. Your app verifies it offline (no callback to the host). Sending the admin token instead makes every options endpoint answer404 no_profilebecause 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.extensionsat OAuth install. Entries lackingblock_origin/block_pathare slotExtensioncontributions 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 initgraduates toward — copy its patterns rather than inventing them. The sync engine on/syncis §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.
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
| Route | Screen | Documented in |
|---|---|---|
| / | Overview — the capability matrix | this section |
| /data | Data Browser — a tab per domain, IndexTable + FilterBar + skeletons, hydration detail panes | §2c, §8b |
| /sync | Sync — install walk → live webhooks → scheduled reconcile, with cursors, counts and schedule health | §6a, §8b |
| /events | Event 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 |
| /connections | Connections — the full connectExternal popup flow, including the denial path | §7a |
| /ops | Ops — deliveries and replay, schedule health, hosted secrets, the CLI commands behind each | §6b, §16a, §17 |
| /settings | Settings — the dirty-state Save/Discard chrome demo, plus where app config really lives | §2b, §16a |
| /kit | Kit 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.