Action reference
← Widgets · Documentation · Next: Themes →
This page defines every built-in action, all fields and defaults, execution semantics, errors, and examples.
Action envelope
Every action is a JSON object with a case-sensitive type:
{
"type": "patch",
"minSpec": 2,
"patch": [
{ "op": "add", "path": "$root/state/status", "value": "ready" }
],
"onError": {
"type": "toast",
"message": "$error.message",
"style": "error"
}
}
Common fields:
| Field | Type | Default | Meaning |
|---|---|---|---|
type | string | required | Built-in action type, or custom |
minSpec | integer | absent | Skip action when greater than BduiSpec.CURRENT |
onError | action | absent | Optional handler for this action's failure |
Unknown, unregistered, and too-new actions are compatibility skips, not errors: log, no-op, and continue a surrounding sync. The server uses X-Bdui-Spec to send compatible behavior.
Catalog
| Group | Actions |
|---|---|
| State/tree | [patch](#patch) |
| Composition | [sync](#sync-and-async), [async](#sync-and-async) |
| Data/logic | [http](#http), [ask](#ask), [jsUpdateState](#jsupdatestate), [invokeTool](#invoketool), [custom](#custom), [validate](#validate) |
| Effects | [dialog](#dialog), [toast](#toast), [navigate](#navigate), [openScreen](#openscreen), [popScreen](#popscreen), [dismiss](#dismiss), [openUrl](#openurl), [copyToClipboard](#copytoclipboard), [share](#share), [analytics](#analytics) |
Context paths
Action values resolve recursively:
| Prefix | Available in |
|---|---|
$root., $local., $parent., $id.<id>. | Action fields that accept resolved values |
$theme. | Any compatible value |
$event. / $event | Action caused by a control change or event |
$response. / $response | HTTP success/error apply and nested actions |
$error. / $error | onError and error observers |
See state and paths. patch path / from use patch paths ($id.offer-42/state/liked), not dotted state paths ($id.offer-42.liked).
Error handling
An action finishes, is skipped, or fails. Exceptions are caught by the runtime and never escape into composition or the SSE collector. A failed mutation is atomic: no partial state/tree commit.
What fails
| Action | Failure |
|---|---|
patch | Missing/duplicate id, invalid pointer, failed test, merge into a non-object, $local/$parent without a current widget |
http | Non-2xx, timeout, connection error, required JSON cannot be parsed, or jsMap fails |
jsUpdateState | Disabled, syntax/runtime error, timeout/limit, invalid return |
custom | Handler throws or returns Fail |
invokeTool | Unknown Tool, invalid input, Tool executor throws, or mapper JS/native failure |
| Navigation/platform effects | Host/platform rejects operation, or openScreen/popScreen without a BduiScreenHost |
dialog | Missing content, unknown presentation, missing tooltip anchorId, missing anchor widget |
toast, analytics | Best effort; malformed values are dropped/logged rather than failed |
Failure pipeline
- Build and log
BduiActionError. - For HTTP, apply optional
errorresponse spec. - Run optional per-action
onError. - Run matching
$errorobservers. - If no handler ran and source is
ui, show the default error toast. - Observer/SSE errors with no handler are log-only.
{
"type": "copyToClipboard",
"value": "$root.shareCode",
"onError": {
"type": "toast",
"message": "Could not copy the code",
"style": "error"
}
}
$error:
| Field | Type | Meaning |
|---|---|---|
actionType | string | Failed action type |
code | string | Stable category (path, patch, http, timeout, js, custom, navigate, platform, …) |
message | string | Safe user-facing summary |
httpStatus | integer/null | HTTP status when available |
source | string | ui, observer, or sse |
sourceWidgetId | string/null | Dispatching widget |
An error handler's own failure is logged and does not re-enter handlers or error observers.
State and tree actions
patch is the only store-mutation action (RFC 6902 plus BDUI node roots and merge). Theme overrides are merge / replace on $root/theme.
patch
Applies an atomic operation list to the widget document and/or node state.
| Field | Type | Default | Meaning |
|---|---|---|---|
targetId | string | root | Base node for paths that start with / or "" |
patch | array/path | required outside HTTP | Operation list |
patch is a spec-2 action.
Patch paths
A path is either a JSON Pointer (RFC 6901) or a BDUI node root plus a pointer:
| Path | Target |
|---|---|
/children/- | RFC 6902 from targetId (default: root widget) |
"" | The targetId node itself |
$id.offer-42 | The widget node with that id |
$id.offer-42/state/liked | JSON Pointer /state/liked on that node |
$root/state/status | Root widget, then /state/status |
$local/state/liked | Current widget (widget-declared actions only) |
$parent/state/title | Nearest ancestor widget (widget-declared actions only) |
$id.<id> runs until the first /. Ids may contain dots (offer-42); they must not contain /. Duplicate ids in the document fail the patch.
$root, $local, $parent, and $id.x resolve to a widget node, not to its state. State is always /state/.... Binding $id.offer-42.liked is the same value as patch path $id.offer-42/state/liked.
$local and $parent are invalid in SSE and HTTP patches (no current widget). Use $root or $id.*.
targetId applies only to paths that start with / or "". $ paths are absolute.
value and from values resolve like other action JSON ($event, $response, dotted state paths). Do not put a dotted state path in path.
Operations
op | Fields | Meaning |
|---|---|---|
add | path, value | Insert a value. Existing object members are replaced. Array indexes insert and shift |
remove | path | Delete an existing value |
replace | path, value | Overwrite an existing value. The target must already exist |
move | from, path | Remove at from, then add at path. Array indexes in path are evaluated after the removal |
copy | from, path | Duplicate the value at from onto path |
test | path, value | Succeed only when the current value equals value (JSON equality, including type) |
merge | path, value | Deep-merge value into the object at path |
merge: arrays and scalars replace; object keys recurse; null is a value, not deletion (not RFC 7386). If path is missing and the parent is an object, merge creates that object. If path already exists, it must be an object.
add on an existing object member still replaces (RFC 6902), so it upserts a key when the parent object exists.
Unrecognized operation members are ignored. Unknown op values, missing required fields, a pointer whose parent does not exist, a missing remove/test/from target, a move into its own descendant, a failed test, $local/$parent without a current widget, and duplicate ids fail the action. replace on an object member creates a missing key (and a missing object parent). It still cannot target a missing array index. All operations validate before commit: one invalid operation leaves the complete tree unchanged.
Prefer $id.* plus a short pointer over a long numeric path from root. Sibling indexes shift after add, remove, and move. Copied widgets keep their id; replace it in the same patch when identity must stay unique. Replacing $id.slot-gallery (empty pointer after the id) replaces that node in its parent and should keep the same id.
Mixed state and tree, one commit:
{
"type": "patch",
"targetId": "slot-reviews",
"patch": [
{
"op": "add",
"path": "/children/-",
"value": { "id": "review-1", "type": "Text", "params": { "text": "Great location" } }
},
{ "op": "replace", "path": "$id.offer-42/state/liked", "value": true },
{ "op": "merge", "path": "$root/state", "value": { "loading": false } }
]
}
add — object member (creates state when the node had none):
{
"type": "patch",
"patch": [
{ "op": "add", "path": "$id.offer-42/state", "value": { "liked": false, "likes": 0 } }
]
}
add — nested member (parent object must already exist):
{
"type": "patch",
"patch": [
{ "op": "add", "path": "$id.offer-42/state/badge", "value": "new" }
]
}
add — insert a child at index 0:
{
"type": "patch",
"patch": [
{
"op": "add",
"path": "$id.slot-reviews/children/0",
"value": { "id": "review-banner", "type": "Text", "params": { "text": "Latest reviews" } }
}
]
}
add — append a child (/-, or targetId plus /children/-):
{
"type": "patch",
"patch": [
{
"op": "add",
"path": "$id.slot-reviews/children/-",
"value": { "id": "review-1", "type": "Text", "params": { "text": "Great location" } }
}
]
}
add — existing object member is replaced:
{
"type": "patch",
"patch": [
{ "op": "add", "path": "$root/params/enabled", "value": false }
]
}
remove — object member:
{
"type": "patch",
"patch": [
{ "op": "remove", "path": "$root/state/loading" }
]
}
remove — array element (later siblings shift down):
{
"type": "patch",
"patch": [
{ "op": "remove", "path": "$id.slot-reviews/children/1" }
]
}
replace — existing value (object keys are created when missing; array indexes must already exist):
{
"type": "patch",
"patch": [
{ "op": "replace", "path": "$root/state/status", "value": "ready" }
]
}
replace — whole child widget:
{
"type": "patch",
"patch": [
{
"op": "replace",
"path": "$id.slot-gallery/children/0",
"value": {
"id": "hero",
"type": "Image",
"params": {
"url": "https://cdn.example.com/hero.jpg",
"contentDescription": "Offer"
}
}
}
]
}
replace — named node in its parent:
{
"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"
}
}
}
]
}
move — object member:
{
"type": "patch",
"patch": [
{ "op": "move", "from": "$root/state/draftTitle", "path": "$root/state/title" }
]
}
move — array element. After from is removed, path uses the shifted indexes:
{
"type": "patch",
"patch": [
{ "op": "move", "from": "$root/children/2", "path": "$root/children/0" }
]
}
move — across nodes (no numeric path from root):
{
"type": "patch",
"patch": [
{ "op": "move", "from": "$id.review-0", "path": "$id.slot-footer/children/-" }
]
}
copy — duplicate a child, then give the copy a unique id:
{
"type": "patch",
"patch": [
{ "op": "copy", "from": "$id.slot-reviews/children/0", "path": "$id.slot-reviews/children/-" },
{ "op": "replace", "path": "$id.slot-reviews/children/1/id", "value": "review-1-copy" }
]
}
test — equality guard, then mutate. A mismatch or type mismatch ("3" vs 3) fails the complete patch:
{
"type": "patch",
"patch": [
{ "op": "test", "path": "$root/state/version", "value": 3 },
{ "op": "replace", "path": "$root/state/status", "value": "ready" }
]
}
merge — into a state object:
{
"type": "patch",
"patch": [
{ "op": "merge", "path": "$id.offer-42/state", "value": { "liked": true, "likes": 11 } }
]
}
merge — into a widget node:
{
"type": "patch",
"patch": [
{ "op": "merge", "path": "$id.offer-42", "value": { "params": { "enabled": false } } }
]
}
merge / replace — root theme (backend layer; BduiThemePolicy still filters). Rejected tokens are logged and dropped; the patch still succeeds. Missing theme is created by merge:
{
"type": "patch",
"patch": [
{
"op": "merge",
"path": "$root/theme",
"value": {
"colors": {
"primary": { "light": "#0057D9", "dark": "#8AB4FF" }
}
}
}
]
}
Write from the control event:
{
"type": "patch",
"patch": [
{ "op": "add", "path": "$root/state/form/agreed", "value": "$event" }
]
}
Several operations in one patch; later operations see earlier ones:
{
"type": "patch",
"patch": [
{ "op": "test", "path": "$root/state/status", "value": "loading" },
{ "op": "replace", "path": "$root/state/status", "value": "ready" },
{
"op": "add",
"path": "$id.slot-reviews/children/-",
"value": { "type": "Text", "params": { "text": "Loaded" } }
},
{ "op": "remove", "path": "$root/state/loading" }
]
}
JSON Pointer escaping after the node root: ~ encodes as ~0, / encodes as ~1.
{
"type": "patch",
"patch": [
{ "op": "add", "path": "$root/state/a~1b", "value": true },
{ "op": "replace", "path": "$root/state/c~0d", "value": 1 }
]
}
That writes state["a/b"] = true and state["c~d"] = 1.
These fail (nothing commits):
{ "type": "patch", "patch": [{ "op": "add", "path": "$root/state/missing/child", "value": 1 }] }
{ "type": "patch", "patch": [{ "op": "replace", "path": "$root/state/absent", "value": true }] }
{ "type": "patch", "patch": [{ "op": "test", "path": "$root/state/count", "value": "0" }] }
The last example fails when count is the number 0: test compares JSON values, not coerced strings.
sync and async
Both contain:
| Field | Type | Default | Meaning |
|---|---|---|---|
actions | action[] | required | Child actions |
sync
Runs children strictly in order. Each child—including HTTP, custom handlers, and nested groups—must finish before the next starts. Later actions see earlier commits.
{
"type": "sync",
"actions": [
{ "type": "patch", "patch": [{ "op": "add", "path": "$root/state/loading", "value": true }] },
{
"type": "http",
"method": "POST",
"url": "/v1/form",
"body": { "fromState": "$root.form" },
"success": { "apply": "none" },
"onError": {
"type": "patch",
"patch": [{ "op": "add", "path": "$root/state/loading", "value": false }]
}
},
{ "type": "patch", "patch": [{ "op": "add", "path": "$root/state/loading", "value": false }] }
]
}
First failed child stops remaining children after error handling. Compatibility skips do not stop the group. State observers run once at group completion/failure.
async
Starts every child without waiting for siblings. Individual commits remain serialized.
{
"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 }]
}
},
{ "type": "analytics", "event": "offer_like_started" }
]
}
One child failure does not cancel siblings or fail the group. Each child runs its own error handling and observer cycle.
Nested async inside sync is awaited as a group: all parallel children finish before the next sync sibling. A nested sync inside async is one ordered job among the parallel jobs.
Data and logic actions
http
Makes a request through the host's Ktor HttpClient.
| Field | Type | Default | Meaning |
|---|---|---|---|
method | string | GET | GET, POST, PUT, PATCH, DELETE |
url | string/path | required | Absolute or host-relative URL. A path that resolves to an empty string skips the action, so a call can depend on state |
headers | object | {} | Recursively resolved strings; never embed auth secrets in JSON |
query | object | {} | Recursively resolved query values |
body | JSON/body spec | absent | Literal JSON, fromState, or fromEvent |
success | apply spec | { "apply": "none" } | Handling for 2xx |
error | apply spec | absent | Optional handling of non-2xx response body |
onError | action | absent | Recovery/reporting after optional error apply |
timeoutMs | integer | client default | May only tighten host policy |
Body from state:
{
"type": "http",
"method": "POST",
"url": "/v1/favorites",
"headers": { "Content-Type": "application/json" },
"query": { "source": "$root.analytics.source" },
"body": { "fromState": "$root.offer" },
"success": {
"apply": "patch",
"from": "$response.ops",
"action": { "type": "toast", "message": "Saved", "style": "success" }
},
"error": {
"apply": "patch",
"from": "$response.ops"
},
"onError": {
"type": "toast",
"message": "$error.message",
"style": "error"
}
}
Body from control event:
{
"type": "http",
"method": "PUT",
"url": "/v1/settings/notify",
"body": { "fromEvent": true },
"success": { "apply": "none" }
}
Apply spec:
| Field | Type | Default | Meaning |
|---|---|---|---|
apply | string | none | none or patch |
from | response path | $response | Payload within body, taken before mapping |
jsMap | string | absent | Sandboxed mapResponse that reshapes the payload before apply |
targetId | string | root | Default targetId for / paths in the ops |
action | action | absent | Runs after successful apply. $response is still the original body |
HTTP status outside 200–299 is failure even if the body is valid. The error apply runs first, then onError, then general error observers. A failed jsMap is an action failure (js / timeout / js_limit / js_disabled), not a successful apply of the raw payload.
apply: "patch" takes an operation array (the from slice).
{
"type": "http",
"method": "GET",
"url": "/v1/offers/42/patch",
"success": {
"apply": "patch",
"from": "$response.ops"
}
}
Mapping a response with JavaScript
Use jsMap when the HTTP body is not already in widget-state shape. It runs after from extraction and before apply.
{
"type": "http",
"method": "GET",
"url": "/v1/profile",
"success": {
"apply": "patch",
"from": "$response.data",
"jsMap": "function mapResponse(payloadJson, currentJson) { var p = JSON.parse(payloadJson); var root = JSON.parse(currentJson); var prev = root.state && root.state.profile ? root.state.profile : {}; return JSON.stringify([{ op: 'merge', path: '$root/state/profile', value: { name: (p.firstName + ' ' + p.lastName).trim(), avatar: p.photo && p.photo.url, likes: typeof p.likes === 'number' ? p.likes : prev.likes } }]); }"
}
}
The script must declare one synchronous function:
function mapResponse(payloadJson, currentJson) { return JSON.stringify(mapped); }
| Argument | JSON string of |
|---|---|
payloadJson | The from slice of the HTTP body (default: whole $response) |
currentJson | The root widget document. Missing root is a path failure |
The returned JSON is what apply consumes:
apply | mapResponse must return |
|---|---|
patch | operation array |
none | ignored; do not use jsMap |
jsMap uses the same isolate, policy, limits, and commit gate as [jsUpdateState](#jsupdatestate). Kotlin parses the return string; functions, host objects, and promises never enter the store.
Typical uses: flatten nested DTO fields, pick/rename keys, coerce types, keep a local field the server omitted (likes in the example). Do not call HTTP, navigate, or mutate anything except the returned JSON.
If jsPolicy.enabled is false, an apply spec that includes jsMap fails with js_disabled and does not fall back to the unmapped payload. Gate jsMap on X-Bdui-Spec so older clients receive an already-shaped body instead.
ask
Asks AppSana to fill a few state paths the widget declares, from a small piece of state the widget names. AppSana runs one short model call that sees only the instruction, the user's words and the context, and may only set the paths in write. Use it when a value depends on something the widget just loaded: pick the doctor a user named from the list that arrived, or the day they meant from the open days.
| Field | Type | Default | Meaning |
|---|---|---|---|
query | string/path | required | The user's own words. An empty value skips the action, so a flow can make the call depend on state |
instruction | string/path | "" | What the widget's author wants done, for example which of the listed items to choose |
context | object | {} | Recursively resolved JSON the model may look at (at most askPolicy.maxContextBytes) |
write | string[] | required | Patch pointers the answer may set, for example $id.root/state/booking/doctor (written as pointers, not resolved) |
ifEmpty | boolean | false | Only write where the target is still missing, null or blank, so a choice the user made is never overridden |
free | boolean | false | Let AppSana write a value that does not appear in context (by default it must be copied from it) |
timeoutMs | integer | policy | May only tighten askPolicy.timeoutMs |
onError | action | absent | Runs on a failure, timeout or when ask is turned off |
{
"type": "sync",
"actions": [
{
"type": "ask",
"instruction": "Choose the doctor the user means from the list.",
"query": "$id.root.doctorQuery",
"context": { "doctors": "$id.root.doctors" },
"write": ["$id.root/state/booking/doctor"],
"ifEmpty": true,
"onError": { "type": "patch", "patch": [{ "op": "add", "path": "$id.root/state/loading", "value": false }] }
},
{ "type": "patch", "patch": [{ "op": "add", "path": "$id.root/state/loading", "value": false }] }
]
}
- Only declared paths change. The SDK applies an
addorreplaceof a path inwrite, and nothing else AppSana sends. A string AppSana writes is data, never a binding (a leading$is escaped). With no clear answer nothing is written and the action succeeds, so the next action runs. - Failures. A server error, a timeout, or a missing or disabled policy fails the action: its own
onErrorruns and a surroundingsyncstops, so give theaskanonErrorthat finishes whatever state the widget keeps (a progress indicator). - Off by default.
BduiConfig.askPolicy = BduiAskPolicy(enabled = true)turns it on. The context leaves the device, so enable it only where users have agreed to that. It needsBduiConfig.appsana. - Older SDKs skip an action they do not know, so the next action still runs and the widget shows what it had.
jsUpdateState
Runs a synchronous pure state transform in a sandboxed JavaScript isolate.
| Field | Type | Default | Meaning |
|---|---|---|---|
path | state-object path | $root | Object passed to and replaced by the script |
jsCode | string | required | One updateState(stateJson) function declaration |
onError | action | absent | Handles disabled/syntax/runtime/timeout/output failure |
{
"type": "jsUpdateState",
"path": "$root",
"jsCode": "function updateState(stateJson) { var s = JSON.parse(stateJson); s.canSubmit = !!(s.form && s.form.email && s.form.agreed); return JSON.stringify(s); }",
"onError": {
"type": "toast",
"message": "Could not validate the form",
"style": "error"
}
}
Contract (shared with HTTP [jsMap](#mapping-a-response-with-javascript) and observer jsPredicate):
- one JSON string in, one JSON string out;
- return the complete replacement object;
- promises are rejected, never awaited;
- no top-level side effects;
- no host bindings, store, HTTP, navigation, DOM, file, clipboard, sockets, timers, or dynamic imports;
- fresh isolate for every call;
- Kotlin JSON parsing is the commit gate.
Default/hard limits:
| Limit | Default | Hard cap |
|---|---|---|
| Wall time | 50 ms | 250 ms |
| Source | 8 KiB | 32 KiB |
| Input | 256 KiB | 1 MiB |
| Output | 256 KiB | 1 MiB |
The watchdog terminates the engine on timeout. Run off the main thread. Web uses a dedicated Worker or QuickJS-Wasm—never page eval.
jsPolicy = BduiJsPolicy(
enabled = true,
timeoutMs = 50,
maxSourceBytes = 8_192,
maxPayloadBytes = 256 * 1024,
)
enabled = false fails with js_disabled. The isolate is a code sandbox, not a confidentiality boundary; do not expose secrets solely for script consumption.
validate
Checks declarative rules against current state, writes the problems to errorsPath, then runs onValid or onInvalid. Spec 8. The rules are data: nothing supplied by a model is executed, and a regular expression is capped at 200 characters.
| Field | Type | Default | Meaning |
|---|---|---|---|
rules | array | required | See below. Missing rules fails the action |
errorsPath | string | $root/state/errors | Patch path that receives { "<key>": "<message>" } |
onValid | action | absent | Runs only when every rule passes. The form is never submitted by an invalid run |
onInvalid | action | absent | Runs when at least one rule fails |
A rule has path (a binding such as $root.form.email), an optional key (the field name in the errors object, default the last path segment), and any of: required, minLength, maxLength, min, max, format (email, phone, number, date), pattern, notBefore (an ISO date or a binding to one). message is used for the first failing check; messages overrides per check (required, minLength, format, notBefore, ...). An empty optional field skips its format checks. The first failing rule per key wins. The errors object is replaced on every run, so fixed fields clear.
Show an error by binding it to the field: TextField "error": "$root.errors.email" (non-empty puts the field in error and shows the message).
{
"type": "validate",
"rules": [
{ "path": "$root.form.title", "key": "title", "required": true, "minLength": 5, "message": "Give the bug a short title" },
{ "path": "$root.form.email", "key": "email", "format": "email" },
{ "path": "$root.search.ret", "key": "ret", "required": true, "notBefore": "$root.search.depart" }
],
"onValid": { "type": "custom", "name": "app:submit", "args": { "title": "$root.form.title" } }
}
custom
Calls an app-registered, namespaced action handler.
| Field | Type | Default | Meaning |
|---|---|---|---|
name | string | required | Registered namespace, e.g. acme:favoriteOffer |
args | object | {} | Recursively resolved JSON arguments |
{
"type": "custom",
"name": "acme:favoriteOffer",
"args": {
"offerId": "$root.offer.id",
"source": "card"
},
"onError": {
"type": "toast",
"message": "$error.message",
"style": "error"
}
}
sealed interface BduiCustomActionResult {
data object Done : BduiCustomActionResult
data class Dispatch(val action: BduiAction) : BduiCustomActionResult
data class Fail(
val message: String,
val code: String = "custom",
) : BduiCustomActionResult
}
Dispatch re-enters the same action runtime. Handlers do not receive BduiStore. An unknown name is a compatibility skip; a registered handler throwing/returning Fail is an error.
invokeTool
Runs an application Tool registered on BduiConfig.tools, then maps the structured result through BduiConfig.mappers into a patch applied by the existing patch engine. UI onClick, observers, and SSE action events all enter BduiRuntime.dispatch — there is no separate Tool invocation path.
| Field | Type | Default | Meaning |
|---|---|---|---|
tool | string | required | Registered Tool name |
arguments | object | {} | Recursively resolved JSON arguments |
{
"type": "invokeTool",
"tool": "get_account_balance",
"arguments": {
"accountId": "primary"
}
}
The Tool itself returns a business result, never patches. If the Tool has mapper source, a mapper converts that result into RFC 6902 ops ((result, context) => Patch[]). Native mappers register with mappers.register; JavaScript mapper source delivered by AppSana registers with mappers.registerJs and runs through the existing BduiJsRuntime (requires jsPolicy.enabled). If mapper source is empty, the SDK emits tool.mapper_missing and asks AppSana to compose widgets from the widget library (built-ins and composite recipes) using the real Tool result. Tools registered with sensitive = true skip that compose step so the result never leaves the device — register a client mapper instead.
Unknown Tool names fail (tool). Invalid input fails (tool_input). Mapper exceptions fail (tool_mapper). Lifecycle events: tool.started, tool.completed, tool.failed, plus mapper events on BduiConfig.toolListener.
Effect actions
Effects do not mutate state and are consumed once. They are not restored after process death.
dialog
Shows an overlay whose content is any widget document. Effects do not enter the view checkpoint; the overlay is not restored after process death.
| Field | Type | Default | Meaning |
|---|---|---|---|
presentation | string | alert | alert, bottomSheet, or tooltip |
content | widget | required | Widget tree rendered in the overlay |
dimColor | color/path | see below | Scrim behind the overlay (#RRGGBB / #AARRGGBB or $theme.colors.*) |
cancelable | boolean | true | Outside tap, back, and Esc close the overlay (tooltip: tap outside) |
gravity | string | Center | alert only. Same values as Box contentAlignment |
anchorId | string | required tooltip | tooltip only. Widget id in this view to attach to. BDUI chooses caret/edge placement |
Default dimColor is #80000000 for alert and bottomSheet, and transparent (#00000000) for tooltip.
content is a normal widget envelope (type, modifier, onClick, params, children, optional id / state / observers). $root and $id.* resolve against this view's document. $local / $parent walk the overlay tree. Overlay widget ids must not collide with ids in this view.
Unknown or too-new widgets in content skip as in this view. Missing content, an unknown presentation, a tooltip without anchorId, or an anchorId that is not in this view fails the action. Invalid dimColor logs and uses the default. gravity is ignored for bottomSheet and tooltip. anchorId is ignored for alert and bottomSheet.
Close the overlay with dismiss from a control in content (or cancelable chrome).
alert — centered (or gravity) modal:
{
"type": "dialog",
"presentation": "alert",
"gravity": "Center",
"dimColor": "#80000000",
"cancelable": true,
"content": {
"type": "Card",
"params": { "variant": "elevated" },
"children": [
{ "type": "Text", "params": { "text": "Delete offer?", "variant": "titleMedium" } },
{ "type": "Text", "params": { "text": "This cannot be undone." } },
{
"type": "Row",
"params": { "horizontalArrangement": "End" },
"children": [
{
"type": "Button",
"onClick": { "type": "dismiss" },
"children": [{ "type": "Text", "params": { "text": "Cancel" } }]
},
{
"type": "Button",
"onClick": {
"type": "sync",
"actions": [
{
"type": "http",
"method": "DELETE",
"url": "/v1/offers/42",
"success": { "apply": "none" }
},
{ "type": "dismiss" }
]
},
"children": [{ "type": "Text", "params": { "text": "Delete" } }]
}
]
}
]
}
}
bottomSheet — sheet from the bottom edge; gravity is ignored:
{
"type": "dialog",
"presentation": "bottomSheet",
"dimColor": "$theme.colors.outline",
"content": {
"type": "Column",
"modifier": [
{ "name": "fillMaxWidth" },
{ "name": "paddingHorizontal", "value": "16" }
],
"children": [
{ "type": "Text", "params": { "text": "Share offer", "variant": "titleMedium" } },
{
"type": "Button",
"onClick": {
"type": "sync",
"actions": [
{ "type": "share", "text": "$root.shareUrl" },
{ "type": "dismiss" }
]
},
"children": [{ "type": "Text", "params": { "text": "Share link" } }]
}
]
}
}
tooltip — attach to a widget in this view. BDUI measures that node and points the tooltip at it (anchorId required):
{
"type": "dialog",
"presentation": "tooltip",
"anchorId": "price-info",
"dimColor": "#00000000",
"content": {
"type": "Text",
"params": { "text": "Price includes utilities", "variant": "bodySmall" }
}
}
The anchored widget stays in this view's tree:
{
"type": "Row",
"children": [
{ "type": "Text", "params": { "text": "$root.price" } },
{
"id": "price-info",
"type": "Button",
"onClick": {
"type": "dialog",
"presentation": "tooltip",
"anchorId": "price-info",
"content": {
"type": "Text",
"params": { "text": "Price includes utilities" }
}
},
"children": [{ "type": "Text", "params": { "text": "?" } }]
}
]
}
dismiss
Closes the foremost dialog overlay. If none is shown and this view is a pushed BDUI screen, pops that screen with a null result (same as system back). Otherwise the host may apply its own back/pop behavior.
openScreen
Pushes a full-screen BDUI destination onto BduiScreenHost. The new screen is not an embedded BduiView: it covers the app window, has its own store/runtime, and typically uses a Scaffold root.
| Field | Type | Default | Meaning |
|---|---|---|---|
initialJson | widget/string | absent | Snapshot for the new screen (inline widget tree, JSON string, or path) |
streamUrl | string/path | absent | SSE URL, same contract as BduiView (sseUrl is accepted as an alias) |
onResult | action | absent | Runs on the caller after the screen pops. $event is the result |
At least one of initialJson or streamUrl is required. Missing both, or calling this outside BduiScreenHost, fails (screen). An inline widget tree is not resolved against the caller; $root in that tree belongs to the new screen. A string path such as "$root.detailJson" is resolved on the caller and then used as the snapshot.
openScreen does not wait for the destination to close. Later sync siblings run immediately.
{
"type": "openScreen",
"initialJson": {
"id": "root",
"type": "Scaffold",
"state": { "title": "Choose a city" },
"topBar": {
"type": "TopAppBar",
"params": { "title": "$root.title" }
},
"children": [
{
"type": "Button",
"onClick": {
"type": "popScreen",
"result": { "title": "Almaty" }
},
"children": [{ "type": "Text", "params": { "text": "Almaty" } }]
}
]
},
"streamUrl": null,
"onResult": {
"type": "patch",
"patch": [
{ "op": "add", "path": "$root/state/picked", "value": "$event.title" }
]
}
}
onResult is optional. System back and cancel pass a null $event.
popScreen
Closes the current pushed BDUI screen and delivers result to the caller's onResult as $event.
| Field | Type | Default | Meaning |
|---|---|---|---|
result | JSON | absent | Value exposed to the caller as $event |
Fails (screen) when this view is not a pushed screen or there is no BduiScreenHost.
{
"type": "popScreen",
"result": { "title": "Almaty", "id": "ala" }
}
analytics
Reports a named event. Best effort: it never fails and never blocks the surrounding action. Use it for events specific to your product. Taps, screen opens and the start and end of flows are already reported by the SDK (see below), so do not repeat those. Place it next to the real action in a sync.
| Field | Type | Default | Meaning |
|---|---|---|---|
event | string | required | Event name; blank names are ignored |
params | object | absent | Resolved like any action value ($root., $event.) |
{
"type": "sync",
"actions": [
{ "type": "analytics", "event": "offer_opened", "params": { "offer": "$root.offerId", "source": "banner" } },
{ "type": "openScreen", "screen": "offer" }
]
}
The action hands the event to BduiConfig.analytics (a BduiAnalytics) when the runtime has opted in (see below); otherwise it is only logged. BduiAppsanaAnalytics is the stock sink. It batches events and posts them to the AppSana /api/events endpoint with the application's credentials:
config.analytics = BduiAppsanaAnalytics(config, scope)
Each event is sent with the id of the AppSana response currently on screen (X-Appsana-Request, delivered through BduiAnalytics.onResponse) and the id of the screen that raised it, so AppSana can join a tap to the answer that produced it. Every event is stamped with the phone's own clock (ts, epoch milliseconds, taken when the event happens rather than when the batch is sent) and its UTC offset (tzOffset, minutes, e.g. 300 for UTC+5), so AppSana can show when people act in their local time. Set config.endUserToken = { currentUserRef } so events and requests are tied to a customer; it is sent as X-Appsana-User and must be an opaque reference, never a name, phone or account number.
Events the SDK raises itself
Reporting is opt-in per surface. A runtime reports only when it is given an analyticsScreen name; every other BduiView stays silent, so your own native-style screens (home, settings, checkout) are never tracked. Name only the AppSana-generated surface:
val runtime = BduiRuntime(config, store, darkTheme, scope, analyticsScreen = "personal_feed")
BduiView(initialJson = json, store = store, runtime = runtime)
// or, when BduiView creates the runtime itself:
BduiView(initialJson = json, store = store, analyticsScreen = "personal_feed")
For a named surface the runtime reports these without any JSON. They are raised by the runtime, so they never change how an action runs or fails. The name is sent as the event's screen.
| Event | Raised when | params |
|---|---|---|
screen_opened | The surface first shows content | root |
widget_clicked | The user taps a widget's onClick | widget, widgetType, action, personalized |
action_started | That tap runs an action that does work (http, invokeTool, custom, or a sync/async containing one) | same as widget_clicked |
action_finished | …and it succeeded | same |
action_abandoned | …and it failed | same |
personalized is always true: only an opted-in surface reports, and that surface shows AppSana-generated UI. The analytics action follows the same rule: on a runtime without analyticsScreen it does nothing. Host code reports anything else through the same sink, for example the text field or microphone: config.analytics?.log("input_started", buildJsonObject { put("mode", "text") }, "personal_feed"). Use mode text or voice.
Event names are lowercase snake_case (a-z 0-9 _ . : -, at most 64 characters). Keep params small (under 2 KB) and free of personal data; AppSana scrubs card numbers, emails and national ids but cannot recognise everything.