Server-sent events
← Observers · Documentation · Next: Compatibility →
SSE incrementally changes a view when data or layout is not available in one payload. An SSE action is the same action a widget dispatches—there is no second mutation format or renderer.
Embedding a stream
BduiView(
initialJson = shellJson,
streamUrl = "/v1/offers/42/bdui/stream",
modifier = Modifier.fillMaxWidth(),
)
| Init | Behavior |
|---|---|
| stream only | Loading until snapshot/root replacement |
| JSON + stream | Render shell immediately, then update |
Wire format
id: 101
event: action
data: {"type":"patch","patch":[{"op":"add","path":"$root/state/status","value":"ready"}]}
id: 102
event: action
data: {"type":"patch","patch":[{"op":"replace","path":"$id.slot-gallery","value":{"id":"slot-gallery","type":"Image","params":{"url":"https://cdn.example.com/1.jpg","contentDescription":"Offer"}}}]}
id: 103
event: snapshot
data: {"id":"root","type":"Column","state":{},"children":[]}
| Event | Data | Behavior |
|---|---|---|
action or omitted | Action JSON | Dispatch through BduiActionRuntime |
snapshot | Root widget | Commit as root after compatibility validation |
ping or : comment | none | Keepalive |
Unknown event names and incompatible actions are logged/skipped. Malformed data is logged and routed to the root error observer when possible; the connection stays alive.
Multiple ordered changes use one sync action:
event: action
data: {"type":"sync","actions":[{"type":"patch","patch":[{"op":"add","path":"$root/state/loading","value":false}]},{"type":"patch","patch":[{"op":"add","path":"$root/state/status","value":"ready"}]}]}
Connection lifecycle
BduiSseSession uses the injected Ktor client:
- GET
streamUrlwithAccept: text/event-stream, app authentication, andX-Bdui-Spec. - Decode each event off the UI thread.
- Enqueue events in arrival order on the same runtime as UI actions.
- Record the latest successfully accepted event
id. - Reconnect with
Last-Event-ID. - Exponential backoff with jitter until this
BduiViewleaves composition. - Stop when the view is disposed.
While disconnected, keep displaying the last valid tree. Connection diagnostics are host policy; avoid repeated user toasts.
Ordering and generation
SSE transport order is not enough when a server restarts/resyncs. An action/snapshot may carry:
{
"type": "patch",
"generation": 12,
"patch": [
{ "op": "merge", "path": "$root/state", "value": { "status": "ready" } }
]
}
generation is a monotonic revision for this view:
- absent: accept in transport order;
- lower than store generation: drop as stale;
- equal: accept only when event ID is newer/idempotent;
- higher: apply and update generation.
Root snapshot establishes generation. Server replay should be idempotent or protected by generation/event IDs.
Filling slots
Initial shell:
{
"id": "root",
"type": "Column",
"state": { "title": "Offer", "loading": true },
"children": [
{ "type": "Text", "params": { "text": "$root.title" } },
{
"id": "slot-gallery",
"type": "Box",
"params": { "contentAlignment": "Center" },
"children": [{ "type": "CircularProgress" }]
},
{
"id": "slot-reviews",
"type": "LazyColumn",
"children": []
}
]
}
Replace gallery:
event: action
data: {"type":"patch","patch":[{"op":"replace","path":"$id.slot-gallery","value":{"id":"slot-gallery","type":"Image","params":{"url":"https://cdn.example.com/hero.jpg","contentDescription":"Offer"}}}]}
Append review:
event: action
data: {"type":"patch","patch":[{"op":"add","path":"$id.slot-reviews/children/-","value":{"id":"review-1","type":"Text","params":{"text":"Great location"}}}]}
State and tree in one event:
event: action
data: {"type":"patch","patch":[{"op":"merge","path":"$root/state","value":{"price":"25 000 000 ₽","loading":false}},{"op":"replace","path":"$id.slot-gallery","value":{"id":"slot-gallery","type":"Image","params":{"url":"https://cdn.example.com/hero.jpg","contentDescription":"Offer"}}},{"op":"add","path":"$id.slot-reviews/children/-","value":{"id":"review-1","type":"Text","params":{"text":"Great location"}}}]}
Prefer $id.* and subtree replacement over numeric pointers from root because sibling indices shift. Keep replacement root IDs stable to preserve Compose identity. $local / $parent are invalid on SSE patches.
State-only events
The tree does not need to change for data:
event: action
data: {"type":"patch","patch":[{"op":"merge","path":"$root/state","value":{"price":"25 000 000 ₽","loading":false}}]}
Any widget bound to these paths recomposes.
Outbound traffic
SSE is one-way. UI-to-server work uses http:
{
"type": "async",
"actions": [
{ "type": "patch", "patch": [{ "op": "add", "path": "$id.offer-42/state/liked", "value": true }] },
{
"type": "http",
"method": "POST",
"url": "/v1/likes",
"body": { "fromState": "$id.offer-42" },
"success": { "apply": "none" },
"onError": {
"type": "patch",
"patch": [{ "op": "add", "path": "$id.offer-42/state/liked", "value": false }]
}
}
]
}
The server can later publish the authoritative count/state.
AppSana transform (POST SSE)
BduiView(streamUrl) is GET-only. AppSana's /api/bdui is request-scoped: it needs the current widget document plus a natural-language query, so the host POSTs once and applies the same action / snapshot / ping events.
Bdui.init(
BduiConfig {
httpClient = appHttpClient
appsana = BduiAppsana(
url = "https://appsana-platform.vercel.app/api/bdui",
apiKey = appsanaApiKey,
)
},
)
BduiAppsanaClient(config, runtime).transform("Make the title larger")
The client sends { "ui": <store snapshot>, "query": "..." } with Accept: text/event-stream, Authorization: Bearer, X-Appsana-Key, and X-Bdui-Spec. Do not put the API key in widget JSON. There is no reconnect: one request produces a patch action (or an error toast). The host HTTP client should disable OkHttp read/call timeouts for this POST — the snapshot is a large data: line and the model can take tens of seconds.
The Android sample reads appsana.url and appsana.apiKey from local.properties (see local.properties.example).
Sensitive data
Root-only sensitiveData lists patch paths that must not leave the device on an AppSana POST. Put personal values in state, bind widgets to $root.*, and declare the pointers:
{
"id": "root",
"type": "Column",
"sensitiveData": [
"$root/state/userName",
"$root/state/profile"
],
"state": {
"userName": "Ivan",
"profile": { "phone": "+7 …", "locale": "ru" }
},
"children": [
{ "type": "Text", "params": { "text": "$root.userName" } }
]
}
BduiAppsanaClient clones the snapshot and replaces leaves under those paths with { "$bdui": "sensitive", "ref": "$root/state/userName" }. The live store is unchanged. Host BduiConfig.sensitiveData is unioned with the document list. Inbound AppSana snapshots and patches are grafted back from the live store: the model cannot overwrite those leaves, cannot clear sensitiveData, and a sentinel copied into params is rewritten to a binding ($root.userName) instead of inlining the real value.
Company streamUrl SSE is not tokenized.
Register Tools that return personal data with sensitive = true. The SDK will not POST their result to AppSana for server-side compose; a client mapper must map the result on the device.
Error behavior
- transport loss: reconnect; keep tree;
- malformed event: log, general error observer, continue;
- action execution failure: normal action
onError+ error observers; - incompatible child/action: skip and continue;
- incompatible root snapshot: keep current valid tree and ask host for update UI;
- unreplayable gap: server sends a current
snapshot.
SSE failures never write directly from the socket thread and never terminate composition.