Raw host-bridge protocol
The window globals, calling conventions, and envelopes the SDK wraps — for debugging and for examples that call the bridge directly.
Prefer the SDK. MiniAppClient handles host resolution, schema
validation, subscription routing, and timeouts — and bundles into a
classic L5-safe IIFE. This page documents the wire so you can
debug it, not so you should hand-roll it.
The SDK is a thin wrapper over window globals the host injects.
Examples that call the bridge directly (and anyone debugging a
"works on L8, not L5" report) need the exact contract below. Every
fact here is the behaviour of the shipped SDK runtime.
Host globals and resolution order
The host injects the bridge on one of two globals. Resolution order
matters — pick the branded one first, fall back to legacy, and
require a callHandler function:
function resolveHost() {
const branded = globalThis.__i99dashHost; // HOST_GLOBAL
if (branded && typeof branded.callHandler === 'function') return branded;
const legacy = globalThis.flutter_inappwebview; // LEGACY_HOST_GLOBAL
if (legacy && typeof legacy.callHandler === 'function') return legacy;
return null;
}Binding to the legacy global when the branded one is present can
resolve to a stale bridge whose v2 car.* handlers return nothing —
the dashboard renders but every value stays —. Match this order.
Push events arrive on globalThis.__i99dashEvents (HOST_EVENTS_GLOBAL).
Subscribe with .on(channel, fn); the host calls
.dispatch(channel, payload).
Two calling conventions
callHandler(name, payload) returns a Promise. The payload and
response shape depend on the family.
Car family — flat payload
car.read, car.subscribe, car.identity, car.connection.subscribe:
// car.read — at most 64 names (CAR_MAX_NAMES)
const r = await host.callHandler('car.read', { names: ['battery_pct'] });
// success: { values: Record<string, number | null>, at: string }
// car.subscribe
const s = await host.callHandler('car.subscribe', {
names: ['battery_pct'],
idempotencyKey: crypto.randomUUID(),
});
// success: { subscriptionId: string, rejected?: string[] }An error envelope is any object with an error key. Treat its
presence as failure — do not read .values off it and silently
render —.
Native-capability families — enveloped payload
display.*, surface.*, cursor.*, gesture.*, pkg.*: wrap the
request and unwrap the response:
const raw = await host.callHandler('display.list', {
params: {},
idempotencyKey: crypto.randomUUID(),
});
// { success: boolean, data?: unknown, error?: { code, message } }
if (raw && raw.success === false) throw new Error(raw.error.code);
const data = (raw && raw.data) || raw;Push envelopes
__i99dashEvents.on('car.signal', (p) => {
// { subscriptionId: string, data: { name: string, value: number | null, at: string } }
});
__i99dashEvents.on('car.connection', (p) => {
// { subscriptionId: string, state: 'connected' | 'degraded' | 'disconnected' | 'unknown' }
});Filter by subscriptionId if you hold more than one subscription on
a channel.
Why the SDK is still the right call
It applies CAR_MAX_NAMES, throws typed errors instead of returning
silent empties, parses responses against schemas, routes signals per
subscriptionId, and resolves the host in the order above. Re-deriving
all of that by hand is the most common source of the bugs this page
exists to explain. It also bundles to a single classic IIFE, so the
"raw keeps the bundle static" argument does not hold — see
Build for L5 + L8.
Related
- v5 migration — the
car.*surface the SDK exposes. - Build for L5 + L8 — bundle the SDK instead of hand-rolling.
- Runtime client — the typed API over this wire.
Host catalog status
What the v2 car catalog ships today versus what is deferred to v5.1, and how to gate on it instead of assuming a signal exists.
v5: single `client.car` controller (bridge v2)
SDK v5 collapses every per-family controller into `client.car`. The host's bridge jumps to v2 in lockstep. Per-call mapping table + what's gone.