Over-the-air widgets
← Extensions · Documentation · Next: Examples →
Status: implemented in the SDK (
bdui.ota) and in the AppSana platform (/api/widgets/*). NeedsBduiSpec10 or newer (Stepperflows need 11). A release is identified bywidgetandversionalone; there is no content hash.
Composite widgets (for example a booking flow) are authored and published in the AppSana admin panel. Documents sent to apps carry a small ref instead of the full subtree. The SDK keeps published definitions in a persistent cache and expands refs on the client. Publishing a new version updates every installed app without an app release.
Definitions are plain BDUI JSON in the existing vocabulary. No code is downloaded or executed, so the capability allowlist model in Extensions is unchanged.
Terms
| Term | Meaning |
|---|---|
| Definition | The widget tree plus its prop contract, as authored in the admin panel |
| Release | An immutable published definition: {widget, version, minSpec}. On the AppSana platform its version is the widget's version-history number |
| Channel | Per application (each is one environment: dev, uat or prod) pointer to the release of one widget that application serves |
| Ref | A stub node in a document that names a release and supplies prop values |
| Manifest | The list of releases a given application and environment currently points at |
Ref node
{
"type": "ref",
"id": "booking",
"widget": "booking-flow",
"version": 7,
"props": { "venueId": "42", "currency": "KZT" },
"modifier": [{ "name": "fillMaxWidth" }],
"inline": null
}
| Field | Required | Notes |
|---|---|---|
type | yes | Always "ref". Reserved; not a registrable custom type |
id | yes | Instance id. Unique in the document like any widget id |
widget | yes | Widget id from the admin panel |
version | yes | Integer release version |
props | no | Values for the definition's prop contract |
modifier | no | Appended after the definition root's own modifiers |
onClick | no | Applied to the expanded root. Overrides the root's own onClick if it has one |
inline | no | The full release (see below). Present when the server believes the client lacks it |
minSpec | no | Same meaning as on any node. Servers set it to the release's minSpec |
Definition (release payload)
{
"widget": "booking-flow",
"version": 7,
"minSpec": 10,
"props": {
"venueId": { "type": "string", "required": true },
"currency": { "type": "string", "default": "USD" },
"onCompleted": { "type": "action" }
},
"outputs": ["selection", "step"],
"root": {
"type": "Column",
"state": { "step": 0, "selection": null },
"children": [
{ "type": "Text", "params": { "text": "$props.venueId" } }
]
}
}
outputs lists the root state keys the host document may read. Everything else in the definition is private.
Prop types: string, number, boolean, object, array, action, widget, and any (accepts every JSON value; the platform infers it for composite widgets that declare no contract). action props let the host document decide what a button does (for example "on completed"). widget props are slots: the prop value is a node that replaces the placeholder.
Props substitution
Expansion is a decode-time, structural substitution, not a runtime path:
- A string value that is exactly
"$props.<name>"is replaced by the prop value, keeping its JSON type. This works inparams,state,modifiervalues, and inside actions. - A string that merely contains
$props.is left alone. - A
widget-typed prop is placed where the definition writes{ "type": "slot", "name": "<name>" }. A missing optional slot expands to nothing. - An unknown or missing required prop makes the instance unresolvable (see failure rules). Optional props use
default, otherwise the key is omitted.
Definitions must not read $root to get external data. They get data through props. The admin panel lints for $root references at publish time. $local, $parent, and $id references that stay inside the definition are fine.
Instance ids
Ids inside a definition are local. On expansion each widget id becomes <refId>:<localId>, and string values of the form $id.<localId>… (state paths and patch paths) plus the targetId and anchorId fields written in the definition body are rewritten to match, so two instances of one widget never collide. The separator is : because path resolution splits $id.<widgetId>.<rest> at the first ., which would break dotted ids. The expanded root takes the ref's id unchanged and never carries a :. Definition-local ids may not contain :, ., or / (publish-time lint).
The definition root has no id of its own. Inside the definition, the reserved local id root means the expanded root, so $id.root/state/selection and $id.root.selection both work and are rewritten to the ref's id. Controls need this because $local in a patch path is the widget that fired the action (a Button, not the definition's root).
Local defs (authoring only)
A composite widget's definition in the admin panel may declare defs next to its root and use them with def nodes. The platform flattens them when it builds the release, so a release never contains defs or def nodes and clients need nothing to support them. They are for reusing a piece inside one widget (a step header, a navigation row, a loading block); sharing between widgets is what ref is for, and defs cannot be called from another widget or from chat.
{
"defs": {
"stepHead": {
"params": ["title"],
"node": { "type": "Text", "params": { "text": "$arg.title" } }
}
},
"type": "Column",
"children": [ { "type": "def", "name": "stepHead", "id": "head1", "title": "{{title}}" } ]
}
$arg.<name>is a whole-value reference to a def parameter (an array argument is spliced when it sits in an array, which is how achildrenparameter works).{{prop}}still means the widget's own prop, and may be passed as an argument. A def may call other defs (up to 8 deep, no cycles).- Every use needs an
id(letters, digits,-,_). Ids inside the def body get it as a prefix (head1-title) and$id.<local>,targetIdandanchorIdreferences to them are rewritten, so two uses never collide.$id.rootis left alone: state lives on the widget root whichever def reads it. - A use's
childrenbecome thechildrenparameter, itsmodifieris appended to the result's, and itsonClickis set on it. - Params may be a list of names or an object (
{ "title": { "sample": "Hello" } }); asampleis only used to preview the def on its own. Every declared param must be passed. - The node and size limits apply to the flattened tree.
Actions and events
Definitions can contain any built-in action. Three questions come up: how a host document hands behavior in, how data flows back out, and which paths mean what after expansion.
Handing actions in: action props
A prop declared "type": "action" carries a complete action object. The definition uses it with the same whole-string substitution as any other prop:
{
"type": "Button",
"params": { "text": "Confirm booking" },
"onClick": {
"type": "sync",
"actions": [
{
"type": "http",
"method": "POST",
"url": "/v1/bookings",
"body": { "fromState": "$parent.selection" },
"success": { "apply": "none" },
"onError": { "type": "toast", "message": "$error.message", "style": "error" }
},
"$props.onCompleted"
]
}
}
Here "$props.onCompleted" sits where an action is expected, so it expands to the host's action object. If the host passes no value for an optional prop, the element is dropped from its array (and the key from its object), so sync just runs the remaining actions. An onClick whose value is dropped leaves the widget non-clickable. The host chooses what happens on completion without the definition knowing:
{
"type": "ref",
"id": "booking",
"widget": "booking-flow",
"version": 7,
"props": {
"venueId": "42",
"onCompleted": {
"type": "sync",
"actions": [
{ "type": "analytics", "event": "booking_done", "params": { "venue": "42", "slot": "$id.booking.selection" } },
{ "type": "navigate", "screen": "booking-success" }
]
}
}
}
An action prop can also be used directly as onClick, an observer action, or a dialog button action:
{ "type": "Button", "params": { "text": "Skip" }, "onClick": "$props.onSkip" }
Data flowing back out: outputs
The host never reaches into a definition's private state. A definition lists outputs, which are keys of its root state, and the host reads them through the ref's id: $id.booking.selection, $id.booking.step. Before firing the action prop, the definition writes whatever the host should see into those keys:
{
"type": "sync",
"actions": [
{ "type": "patch", "patch": [{ "op": "add", "path": "$id.root/state/selection", "value": "$parent.slotPicker.value" }] },
"$props.onCompleted"
]
}
The host action above then reads $id.booking.selection. outputs is a contract, not an enforced boundary: the client does not block a host path that reads another key. The admin panel lints host documents it builds and warns on keys outside outputs, because those keys can change between versions. Hosts must not write to a definition's state.
Event values
$event behaves as it does everywhere: it is the value that triggered the action, resolved at dispatch time. A control inside a definition (for example a Select with onChange) can pass $event to an action prop's handler, but only when the definition's own action receives it first. Definitions can forward it explicitly through the wrapper action:
{
"type": "Select",
"params": { "options": "$props.slots" },
"onChange": {
"type": "sync",
"actions": [
{ "type": "patch", "patch": [{ "op": "add", "path": "$id.root/state/selection", "value": "$event" }] },
"$props.onSlotChanged"
]
}
}
Path scopes in action props
A prop value is authored in the host document, not in the definition. So after substitution:
Path in an action prop | Meaning |
|---|---|
$root. | The view root. Same as in the host |
$id.<hostId>. | A host widget by its global id. Never rewritten |
$id.<refId>.<output> | An output of the definition (the ref's id is the root id; see outputs) |
$local. / $parent. | Relative to the node where the action fires, which is now inside the definition. Do not use; the lint rejects them in action props |
$props. | Not valid in a host prop value |
In contrast, $id.<localId> paths written in the definition body are rewritten to <refId>:<localId>. Prop values are not touched by the rewrite, so host ids and definition ids can never be confused.
Errors and compatibility
onErrorinside the definition and inside a host-supplied action behave exactly as in any tree (Actions - errors).- A
customaction in a definition that the app has not registered is a compatibility skip, never an error. Servers should only publish definitions to channels whose apps register what they use; the admin panel can warn from each application's advertised capabilities. - An action with
minSpecabove the client is skipped inside a definition the same as anywhere. Publish-timeminSpecfor the release is the maximum over all nodes and actions used.
Immutability
A release is identified by widget and version alone and is never changed after publishing; a change is a new version. There is no content hash, so clients trust a cached widget@version as is. A ref must name the version it expects, and the SDK ignores an inline release whose widget or version differs from the ref's.
Wire protocol
Request headers
Every document, stream, and widget request already sends X-Bdui-Spec (see Compatibility). Spec 10 clients also send:
X-Bdui-Spec: 10
X-Bdui-Widgets: booking-flow@7,promo-card@3
X-Bdui-Widgets lists releases whose definition is in the client cache. It is capped (see limits) and ordered most recently used first; a client that holds more than the cap sends the most recent.
Server choice per ref
| Client spec | Server sends |
|---|---|
| < 10 | A fully expanded tree. No refs, ever |
≥ 10, release listed in X-Bdui-Widgets | Ref with inline: null |
| ≥ 10, release not listed | Ref with inline set to the release payload |
inline is the escape hatch that keeps the first render correct without a second round trip. Servers may choose to omit inline and let the client fetch; clients must handle both.
Endpoints
GET /api/widgets/manifest
GET /api/widgets/{id}/{version}
Both sit next to the transform endpoint (.../api/bdui → .../api/widgets/..., the same derivation used for tools and events) and are authenticated with the same key headers, scoped by application and environment.
Manifest
{
"etag": "W/\"7f3a\"",
"releases": [
{ "widget": "booking-flow", "version": 7, "minSpec": 10, "size": 18342 },
{ "widget": "promo-card", "version": 3, "minSpec": 10, "size": 2210 }
]
}
Supports If-None-Match → 304. The manifest reflects the channels for the caller's application and environment, so rolling back means repointing a channel and the next sync converges.
Definition GET /api/widgets/{id}/{version}
Returns the release payload. The URL names an immutable release, so responses carry Cache-Control: private, max-age=31536000, immutable: clients cache them forever, but shared caches must not, because access depends on the caller's application. A release the caller's application does not serve returns 404.
Client behavior
Cache
- Persistent, keyed by
widget@version, value is the release payload. - An entry that no longer parses, or is stored under the wrong key, is deleted on read and fetched again.
- Eviction runs after each successful manifest sync: keep every release the manifest references, plus the single highest older version of each of those widgets (the "last good"). Everything else is removed.
- Platform storage sits behind the small
BduiWidgetCacheinterface. Provided:SharedPreferencesWidgetCache(context)(Android),UserDefaultsWidgetCache()(iOS),LocalStorageWidgetCache()(web), andInMemoryWidgetCache()(default, lost on restart). Enable withBduiConfig { otaWidgets = BduiOtaWidgets(cache = …) }; it needshttpClientandappsanafor network fetches.
Sync
- On
Bdui.init, on app foreground, and at most once per 10 minutes thereafter, fetch the manifest with itsETag. - Prefetch releases the manifest lists that are missing from the cache, in the background, never blocking UI.
- Manifest failure is silent. The cache keeps serving.
Expansion point
Refs may enter the store through initialJson, an SSE snapshot, or any patch that adds or replaces nodes. So expansion happens at the store write boundary, once, before commit. The store only ever contains fully expanded trees; the runtime, observers, actions, and $id paths see no refs.
Resolution order for a ref:
inlinepresent and for the samewidgetandversion→ use it and cache it.- Cache hit for
widget@version→ use it. - Fetch
GET /api/widgets/{id}/{version}with a short timeout (default 3 s), cache, use. - Cache has a different version of the same widget (last good) → use it and log.
- Otherwise skip the node and its subtree, log only, like any unknown widget.
A release whose minSpec exceeds BduiSpec.CURRENT is treated as unresolvable and falls to step 4 or 5.
Safety
- The definition vocabulary is validated by the same decoder as server-sent trees, so unknown types are skipped per Compatibility.
- Custom widgets in a definition must be registered in the host's
BduiConfig(allowlist unchanged). - Prop values come from the document and are never evaluated as paths during substitution.
- Expansion is bounded: max nesting depth 8 refs deep, max 2000 nodes per expanded instance, max release size 256 KB. Exceeding a bound makes the instance unresolvable.
Server behavior (platform)
Publishing
- Author or edit a definition in the admin panel; run the existing review workflow.
- A published composite widget goes out over the air automatically: on save, on review approval and on restoring an older version, the application(s) it is assigned to are pointed at its newest servable version. There is no separate release step and no separate counter: version N of the widget's history is release N, and the payload apps download is built from that version's saved snapshot (
{widget, version, minSpec, props, outputs, root}). - A history version is servable when it was published and converts to the over-the-air form: prop contract well-formed, every
$props.*used declared, no$rootreferences, ids unique and free of:,.,/, size and depth bounds respected,minSpeccomputed from the nodes and actions used. Other versions are skipped, so numbers can have gaps. A widget that cannot be served keeps its last version and the console says why. - Each application points at one version of each widget (
(widget, application) → version), so rollback is repointing one row. An application that already serves identical content keeps its version number across a save, so app caches are not invalidated by a save that changed nothing an app can see.
Emitting documents
When the document builder includes a published widget, it emits a ref and fills inline according to the table above. For clients below spec 10 it expands the release into the tree itself, using the same substitution rules as the SDK, so there is one reference implementation of expansion shared by server and tests.
Limits
| Limit | Value |
|---|---|
X-Bdui-Widgets entries | 64 |
| Release size | 256 KB |
| Expanded nodes per instance | 2000 |
| Ref nesting depth | 8 |
Compatibility
BduiSpec.CURRENTbecomes 10. Spec 10 adds therefnode type,X-Bdui-Widgets, and the/api/widgets/*endpoints.- Clients below 10 never receive refs; the server expands for them.
- No manifest signing in this version. Integrity relies on TLS and on releases being immutable; there is no content hash. If definitions later move to a CDN or bucket under separate credentials, add a signed manifest then; the manifest shape leaves room for a
signaturefield.
Test plan (shared by server and SDK)
A set of golden fixtures (definition + props + expected expanded tree) that both the platform's reference expander and the Kotlin expander must pass:
- scalar, object, and array prop substitution keeping JSON types
- default and missing required props
- slot props, with and without a value
- two instances of one widget in one document (id rewriting,
$idreferences) actionprop inonClick, in asynclist, and left unset- host action reading an
outputskey after the definition writes it $id.<hostId>in an action prop is not rewritten while$id.<localId>in the body is- nested refs and the depth bound
inlinefor a different version,minSpectoo high, size bound- fallback to last-good when the requested version cannot be fetched