Skip to content

Architecture

Mini-app bridge (web side) ​

A mini-app is a web page that runs inside your Flutter app, in the webview_sdk_advanced container. The page cannot reach the phone by itself. It asks the app, through the bridge, and the app decides if the page may do it.

archipelago-miniapp.js is the web half of the bridge. It has no dependencies. It gives you:

  • invoke(capability, method, params), which returns a Promise;
  • typed errors that match the app's error codes;
  • host events;
  • a mock mode, so the page works in a normal browser while you develop it.

The same file works with the same-engine container shipped today. The planned partner host keeps the same envelope and the same error codes.

Using TypeScript or the partner host? Use the typed @archipelago/miniapp-sdk instead.

Get the file ​

archipelago generate webview_sdk_advanced puts these files in your project, in features/webview_advanced/web/:

FileUse
archipelago-miniapp.jsES module. Use it with a bundler or <script type="module">.
archipelago-miniapp.umd.jsPlain script. It sets the global ArchipelagoMiniApp.

Copy one of them into your web project. It is one small file with no build step.

Quick start ​

html
<script type="module">
  import { invoke, isInsideApp, BridgeError } from './archipelago-miniapp.js';

  await invoke('lifecycle', 'ready');                 // tell the app the page is up
  const { authenticated } = await invoke('auth', 'getAuthState');

  try {
    const { token } = await invoke('auth', 'getToken');
    // call your API with `Authorization: Bearer <token>`
  } catch (e) {
    if (e instanceof BridgeError && e.code === 'CAPABILITY_DENIED') {
      // the manifest does not grant `auth`
    }
  }
</script>

Script tag, no bundler:

html
<script src="archipelago-miniapp.umd.js"></script>
<script>
  ArchipelagoMiniApp.invoke('navigation', 'close');
</script>

The default client is archipelago (import { archipelago }). Use createClient(options) when you need your own timeout, stubs or transport.

How a call works ​

Each call sends one envelope to the native handler archipelago_bridge:

json
{ "v": "1.0", "id": "m1abc-1", "capability": "auth", "method": "getToken", "params": {} }

The app answers with one of these:

json
{ "ok": true,  "result": { "token": "...", "expiresIn": 300 } }
{ "ok": false, "error": { "code": "CAPABILITY_DENIED", "message": "..." } }

invoke resolves with result, or rejects with a BridgeError. The app checks every call in this order: version, page origin, capability grant, method. The first check that fails ends the call.

The default timeout is 10 seconds. Change it for the client with createClient({ timeoutMs }) or for one call with invoke(c, m, params, { timeoutMs }). A timeout rejects with TIMEOUT.

Version negotiation ​

The client offers SUPPORTED_VERSIONS (today ['1.0']), preferred first. If the host publishes window.archipelago.version and the client supports it, the client uses it. If the host answers VERSION_MISMATCH with the version it expects, the client retries once with that version when it supports it. Otherwise the call rejects with VERSION_MISMATCH. The version in use is client.version.

Capabilities ​

The list below comes from MiniAppCapability and the handlers in webview_advanced. You can only call a capability that the mini-app manifest grants. lifecycle is always granted.

CapabilityMethodsResultRemote-safe
lifecycleready(), setTitle({ title })nullYes
navigationclose(), openDeepLink({ path }), canGoBack()null, null, booleanYes
analyticstrack({ name, params }), trackScreen({ screenName, params })nullYes
shareshare({ text, url }) (url must be http or https)nullYes
hapticsimpact({ style }) (light, medium, heavy)nullYes
themeget(){ brightness, tokens }Yes
authgetToken({ scopes }), getAuthState(){ token, ... }, { authenticated }No
profilegetProfile()claims named in profile_claimsNo
storageget({ key }), set({ key, value }), remove({ key })string or null, null, nullNo
permissionsrequest({ permission }), status({ permission })granted, denied or permanentlyDeniedNo

"Remote-safe" means a manifest that arrives from remote config can grant it. A mini-app whose id is not in the app's bundled YAML can only get the remote-safe set. The other four capabilities need the id to ship inside the app. See Security.

Notes:

  • openDeepLink only accepts paths that the host app listed as allowed.
  • storage keys are private to the mini-app. The app stores them, not the web view, and enforces a quota.
  • share allows one open share sheet at a time and has a short cooldown. A call that breaks either rule fails with SHARE_IN_PROGRESS or SHARE_COOLDOWN.
  • permissions names are camera, microphone, photos and the others in MiniAppPermissionType.
  • profile needs auth to be granted too. Declare both.
  • theme is read-only design tokens, no user data. See Follow the app theme.

Follow the app theme ​

Grant theme in the manifest and the page can use the same look as the app. Two pieces work together:

  1. The preset. In the app project, run dart run monorepo_toolkit ui-kit export-tailwind (ui_kit_generator). It writes a Tailwind v4 file with the app's real colors (light and dark), type, spacing, radius and shadows as CSS variables. Load it in the mini-app's build.
  2. The capability. theme.get() returns the app's current values. The app also sends a theme.changed event when the app theme or the system light/dark mode changes.
js
import { archipelago } from './archipelago-miniapp.js';

// Sets <html data-theme="light|dark"> and the CSS variables, then follows changes.
const stop = await archipelago.followTheme();

Or do it by hand:

js
const { brightness, tokens } = await invoke('theme', 'get');
// brightness: 'light' | 'dark'
// tokens: { '--color-bg': '#EEF2FF', '--color-action-primary': '#4F46E5', '--radius-md': '8px', ... }

archipelago.on('theme.changed', ({ brightness, tokens }) => { /* same shape */ });
  • The variable names are the same as in the preset. tokens holds the semantic colors (background, foreground, action, border, status, overlay), the radius scale and the spacing scale, with the values for the current mode. Palettes, type and shadows come from the preset; the data-theme attribute switches their dark values.
  • The app waits 300 ms after the last theme change before it sends theme.changed, so a theme animation sends one event, not many.
  • The app only sends the event while the page is on a bridge origin.
  • Outside the app, theme.get returns { brightness: 'light', tokens: {} } and followTheme() sets data-theme="light".
  • The Bridge inspector lists each theme.changed push.

Auth token flow ​

  1. The manifest grants auth and sets token_strategy.
  2. The page calls invoke('auth', 'getToken').
  3. The app never gives the page the user's session or refresh token. It returns one of:
    • interceptor (default). { token }, made by the host's MiniAppAuthProvider. The app also adds Authorization: Bearer ... to requests the page makes to its bridge_origins, and only to those.
    • exchange. { token, expiresIn, scopes }. The app asks your backend's exchange endpoint for a token that is bound to this mini-app, short-lived (300 seconds or less) and limited to the scopes you asked for. Pass scopes to ask for a subset: invoke('auth', 'getToken', { scopes: ['rewards.read'] }).
  4. The page uses the token for its own API calls. When it expires, call getToken again. Do not cache it in localStorage.

Errors from the exchange: AUTH_EXPIRED (the user's session ended; the host should sign the user in again), UNKNOWN_APP (the backend does not know this mini-app id) and INTERNAL.

Errors ​

invoke rejects with BridgeError. Read error.code, not the message. The message is for developers.

js
import { BridgeError, ErrorCode } from './archipelago-miniapp.js';

try {
  await invoke('storage', 'get', { key: 'last-tab' });
} catch (e) {
  if (e instanceof BridgeError && e.code === ErrorCode.CAPABILITY_DENIED) { /* ... */ }
}
CodeMeaning
VERSION_MISMATCHHost and page speak different protocol versions and the client cannot match.
ORIGIN_DENIEDThe page's current URL is not an exact match for a manifest bridge_origins entry. The app checks this on every call.
CAPABILITY_DENIEDThe manifest does not grant this capability.
METHOD_NOT_FOUNDThe capability has no such method, or the host did not install a handler for it.
BAD_REQUESTThe envelope was malformed or larger than 64 KiB.
HANDLER_ERRORThe handler failed. This includes bad params. The message is generic on purpose.
AUTH_EXPIRED, UNKNOWN_APP, INTERNALToken exchange failures (see above).
SHARE_IN_PROGRESS, SHARE_COOLDOWNshare limits.
PAYLOAD_TOO_LARGE, RATE_LIMITED, SESSION_INVALIDPartner host only.
TIMEOUTNo answer in time. Client side.
NOT_IN_APPmock: false and the page is not in the app. Client side.
TRANSPORT_ERRORThe native call failed or the answer was not a bridge response. Client side.

A host can add new codes. The client passes them through, so treat an unknown code as a failure.

Host events ​

The host sends events to the page with controller.postMessageToJs(name, payload). The client turns them into subscriptions:

js
const off = archipelago.on('resume', (payload) => refresh(payload));
archipelago.on('*', (payload, name) => console.log(name, payload)); // every event
off();

No built-in capability sends events yet. Your app sends the events it needs.

Standalone development ​

Outside the app there is no native side. By default the client then switches to mock mode. It prints one console message and answers calls from a stub table. Open the page in a normal browser and develop as usual.

js
import { createClient } from './archipelago-miniapp.js';

const client = createClient({
  stubs: {
    'auth.getAuthState': { authenticated: false },                 // a plain value
    'auth.getToken': (params) => ({ token: 'dev-token', expiresIn: 300, scopes: params.scopes ?? [] }),
  },
});
client.mock.emit('resume', { reason: 'test' });                    // fake a host event

Options:

OptionDefaultEffect
mock'auto''auto': stubs outside the app. true: stubs always. false: reject with NOT_IN_APP outside the app.
stubsnonePer capability.method overrides. A value, or a function that gets params.
readyTimeoutMs300How long the first call waits for the WebView to inject its bridge before it falls back to mock mode.
timeoutMs10000Per-call timeout.
transportautoYour own { send(envelope), onEvent(fn) }. Use it in tests.

Default stub responses. They have the same shape as the real ones, with fake values:

CallStub response
theme.get{ brightness: 'light', tokens: {} }
lifecycle.*, navigation.close, navigation.openDeepLink, analytics.*, share.share, haptics.impactnull
navigation.canGoBackfalse
auth.getToken{ token: 'mock-token', expiresIn: 300, scopes: params.scopes or [] }
auth.getAuthState{ authenticated: true }
profile.getProfile{ sub: 'mock-user', name: 'Mock User' }
storage.get/set/removean in-memory map; get returns null for a missing key
permissions.request, permissions.status'granted'
any other callrejects with METHOD_NOT_FOUND

Mock mode does not check manifests or origins. Test the real grants inside the app, with the Bridge inspector.

Detect the app with isInsideApp(). It is true only after the WebView has injected its native bridge, so on page load use await archipelago.ready(), which resolves true in the app and false in a browser.

Security ​

  • Exact bridge origins. The bridge only answers pages whose URL matches a manifest bridge_origins entry exactly (scheme, host and port). The app checks again on every call, so a redirect in the middle of a session cannot reach the bridge. allowed_origins is separate: it lets the page navigate to those origins (for example an OAuth redirect) without giving them the bridge.
  • Grants. A call to a capability that the manifest does not list fails with CAPABILITY_DENIED before any handler code runs. Grant the minimum.
  • Remote manifests. A mini-app id that is not in the bundled YAML gets only the remote-safe capabilities. auth, profile, storage and permissions always need a build that ships the id.
  • Main frame only. The bridge is for the top-level page. Iframes do not get it.
  • Scoped tokens. Pages get a mini-app token, never the session. Prefer token_strategy: exchange for any mini-app you do not fully control.
  • No secrets in the page. Everything you ship in the page is readable by the user. Keep keys on your backend.
  • Error text. The app does not put internal details in error messages. Do not parse messages. Use the code.

See also ​