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/:
| File | Use |
|---|---|
archipelago-miniapp.js | ES module. Use it with a bundler or <script type="module">. |
archipelago-miniapp.umd.js | Plain 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
<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:
<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:
{ "v": "1.0", "id": "m1abc-1", "capability": "auth", "method": "getToken", "params": {} }The app answers with one of these:
{ "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.
| Capability | Methods | Result | Remote-safe |
|---|---|---|---|
lifecycle | ready(), setTitle({ title }) | null | Yes |
navigation | close(), openDeepLink({ path }), canGoBack() | null, null, boolean | Yes |
analytics | track({ name, params }), trackScreen({ screenName, params }) | null | Yes |
share | share({ text, url }) (url must be http or https) | null | Yes |
haptics | impact({ style }) (light, medium, heavy) | null | Yes |
theme | get() | { brightness, tokens } | Yes |
auth | getToken({ scopes }), getAuthState() | { token, ... }, { authenticated } | No |
profile | getProfile() | claims named in profile_claims | No |
storage | get({ key }), set({ key, value }), remove({ key }) | string or null, null, null | No |
permissions | request({ permission }), status({ permission }) | granted, denied or permanentlyDenied | No |
"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:
openDeepLinkonly accepts paths that the host app listed as allowed.storagekeys are private to the mini-app. The app stores them, not the web view, and enforces a quota.shareallows one open share sheet at a time and has a short cooldown. A call that breaks either rule fails withSHARE_IN_PROGRESSorSHARE_COOLDOWN.permissionsnames arecamera,microphone,photosand the others inMiniAppPermissionType.profileneedsauthto be granted too. Declare both.themeis 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:
- 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. - The capability.
theme.get()returns the app's current values. The app also sends atheme.changedevent when the app theme or the system light/dark mode changes.
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:
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.
tokensholds 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; thedata-themeattribute 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.getreturns{ brightness: 'light', tokens: {} }andfollowTheme()setsdata-theme="light". - The Bridge inspector lists each
theme.changedpush.
Auth token flow
- The manifest grants
authand setstoken_strategy. - The page calls
invoke('auth', 'getToken'). - The app never gives the page the user's session or refresh token. It returns one of:
interceptor(default).{ token }, made by the host'sMiniAppAuthProvider. The app also addsAuthorization: Bearer ...to requests the page makes to itsbridge_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. Passscopesto ask for a subset:invoke('auth', 'getToken', { scopes: ['rewards.read'] }).
- The page uses the token for its own API calls. When it expires, call
getTokenagain. Do not cache it inlocalStorage.
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.
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) { /* ... */ }
}| Code | Meaning |
|---|---|
VERSION_MISMATCH | Host and page speak different protocol versions and the client cannot match. |
ORIGIN_DENIED | The page's current URL is not an exact match for a manifest bridge_origins entry. The app checks this on every call. |
CAPABILITY_DENIED | The manifest does not grant this capability. |
METHOD_NOT_FOUND | The capability has no such method, or the host did not install a handler for it. |
BAD_REQUEST | The envelope was malformed or larger than 64 KiB. |
HANDLER_ERROR | The handler failed. This includes bad params. The message is generic on purpose. |
AUTH_EXPIRED, UNKNOWN_APP, INTERNAL | Token exchange failures (see above). |
SHARE_IN_PROGRESS, SHARE_COOLDOWN | share limits. |
PAYLOAD_TOO_LARGE, RATE_LIMITED, SESSION_INVALID | Partner host only. |
TIMEOUT | No answer in time. Client side. |
NOT_IN_APP | mock: false and the page is not in the app. Client side. |
TRANSPORT_ERROR | The 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:
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.
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 eventOptions:
| Option | Default | Effect |
|---|---|---|
mock | 'auto' | 'auto': stubs outside the app. true: stubs always. false: reject with NOT_IN_APP outside the app. |
stubs | none | Per capability.method overrides. A value, or a function that gets params. |
readyTimeoutMs | 300 | How long the first call waits for the WebView to inject its bridge before it falls back to mock mode. |
timeoutMs | 10000 | Per-call timeout. |
transport | auto | Your own { send(envelope), onEvent(fn) }. Use it in tests. |
Default stub responses. They have the same shape as the real ones, with fake values:
| Call | Stub response |
|---|---|
theme.get | { brightness: 'light', tokens: {} } |
lifecycle.*, navigation.close, navigation.openDeepLink, analytics.*, share.share, haptics.impact | null |
navigation.canGoBack | false |
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/remove | an in-memory map; get returns null for a missing key |
permissions.request, permissions.status | 'granted' |
| any other call | rejects 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_originsentry 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_originsis 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_DENIEDbefore 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,storageandpermissionsalways 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: exchangefor 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
webview_sdk_advancedbrick: the container, manifests and providers.app_debugger_miniapp: the Bridge inspector shows each call, withauthresults hidden.