af — action flows (.af) and the full action table
Audience: people building a tool, and the AI working on their behalf. Both read this same page.
What it is
.af is the flow that runs after the user moves a finger: tapping a widget, long-pressing to edit, pressing a button on a page, submitting a form. It is allowed to have side effects — show a toast, fire haptics, open a page, write local data, write parameters back to a widget, make a widget fetch again.
It runs on the same ActionFlow engine and the same syntax as the data flow .df; there is exactly one dividing line: .df is an allowlisted subset with zero UI, zero navigation and zero App-injected capability; .af is the full set. Fetching goes in .df, side effects go in .af. The extension is the capability declaration: get it wrong and the App answers "flow not found" outright (check G12b). For data-flow syntax, see numable docs df.
The engine then cuts again by carrier, and this cut is harder than the extension:
| Event flow | Render flow | |
|---|---|---|
| Test | The user's finger is involved | Everything else |
| Entry points | Tapping a whole widget on the dashboard · tapping an RCN cell · a home-screen widget tap replaying onClick · a deep link · XPage node events · form onSubmit |
canvas.depends in .xwidget · XPage node depends · banner flow |
ui.* / nav.* / widget.* |
Available | Do not exist at the type level; unreachable |
data.* / request |
Available | Available |
| Network allowlist | Enforced | Enforced |
So "show a toast while fetching / refresh myself while fetching" is impossible. Writing it raises no error; that step simply does not exist.
A minimal working example
The flow that runs when "Save" is pressed on a page (page/flow/save.af). Write to disk, then make this package's widgets fetch again: this is the most common shape of an .af.
{
"version": 1,
"actions": [
{ "action": "ui.haptic", "params": { "type": "tap" } },
{ "op": "set", "props": { "key": "lk", "value": "log_${id}" } },
{ "op": "set", "props": { "key": "sk", "value": "stat_${id}" } },
{ "action": "data.set", "params": { "key": "${lk}", "value": "${log}" } },
{ "action": "data.set", "params": { "key": "${sk}", "value": "${stat}" } },
{ "action": "data.set", "params": { "key": "agg", "value": "${agg}" } },
{ "action": "widget.refresh" }
]
}
Line by line: ui.haptic sits at actions[0] (nothing else gives feedback between the press and the screen changing) · op:set lands the dynamic key names as variables first · three data.set calls write to disk · finally widget.refresh makes this package's widgets really fetch and repaint. Every write must come before widget.refresh, otherwise the refresh reads the old disk.
File format
{
"version": 1, // required, always 1
"inputs": ["secid", "period"], // optional; declares expected inputs, static check only
"i18n": { "zh-CN": { "k": "文案" }, // optional: this file's own content table (A), read with ${@i18n.k}
"en-US": { "k": "text" } },
"actions": [ /* the array of steps */ ]
}
A _note field may appear anywhere; it is prose for humans and AI, and the engine ignores it.
The three shapes a step can take
| Shape | Written as | Notes |
|---|---|---|
| action step | { "id": "resp", "action": "request", "params": { … } } |
Calls one action; id = the result key |
| operation step | { "op": "set", "props": { "key": "x", "value": "…" } } |
Control flow and assignment; children go in items |
| composite step | { "action": "concurrent", "items": [ … ] } |
sequential / concurrent go in the action slot, not the op slot |
A composite step written as {"op":"concurrent"} is skipped silently as a whole — the flow still reports success and not one action inside it ran.
policy (optional, set on a step): wait (default, wait for it) / detach (don't wait) / skip (skip it).
params are evaluated recursively: strings go through the expression engine, object keys are evaluated too, arrays element by element; when a whole-value @[…] reference cannot be resolved it is passed through literally. For expressions and methods, see numable docs methods.
id = the result key
An action step with an id writes its result into scope under that key, and you read it later with ${id.xxx}. The result is discarded in two cases: no id; or the result is null or undefined.
The second one is the most common source of silent failure: if data.get finds nothing and you did not write a default, that key never appears in scope at all, and every downstream ${k} is permanently empty without an error. Always write a default on data.get.
Except across concurrent branches, a written key is visible to the very next step.
The four shapes of an inline flow binding
A node's depends, the value of an events entry, and an onSubmit binding all use one of these four shapes:
"depends": [
{ "flow": "@[file://page/flow/quote.df]", "params": { "secid": "${secid}" } }, // ① file reference + inputs (recommended)
{ "flow": { "version": 1, "actions": [ … ] }, "params": { "x": "${y}" } }, // ② inline object
"@[file://page/flow/noop.df]", // ③ bare reference, inputs = {}
{ "op": "set", "props": { "key": "cards", "value": [] } } // ④ bare inline single step
]
flow written as an array does nothing. The bare-reference shape passes empty inputs — if the flow needs inputs and you write shape ③, it still succeeds and the widget paints a wall of -- (check G12c). Keys that are not used for fetching but do get displayed (a city name, say) must also be listed in params.
The path base of @[file://…] follows the carrier
| Carrier | Base | Written as |
|---|---|---|
events in .xwidget · RCN cell events |
xWidget/ |
@[file://flow/mark-today.af] |
Pages (the page router.json points at, nodes inside it, onSubmit) |
Package root | @[file://page/flow/save.af] |
The symptom of a wrong base is "tapping does nothing": the App reads no actions and ends silently (check G12d).
The full action table
Built into the engine (the .df column = also usable in render flows / data flows)
| action | Inputs | Output | .df |
|---|---|---|---|
cancel |
— | — | ✅ |
error |
errorMsg |
— | ✅ |
finish |
— | — | ✅ |
sleep |
timestamp (milliseconds, written as a string "1500"; the parameter really is named timestamp) |
— | ✅ |
log |
log |
— | ✅ |
resultFilter / resultfilter |
keys[] |
The filtered result set | ✅ |
request |
url (required) · method (default GET) · queryParams (object) · header (object) · formData (multipart array) · body (string) · formatType (string default / json / xml / base64 / tsv / csv) · columns (string array, tsv / csv only) · credential (credential declaration id, a literal) · timeout (milliseconds) |
The response | ✅ |
clearCookie |
pattern (regex) |
— | ✅ |
htmlParse |
content · rules (an array of rules; see numable docs df) |
Structured result | ✅ |
xmlParse |
content · encoding · rules |
Structured result | ✅ |
showLoading |
progress |
— | ❌ |
hideLoading |
status · message · delay |
— | ❌ |
toast |
message · style = success / warning / error |
— | ❌ |
data.get |
key · default |
The value / default / null |
✅ |
data.set |
key · value |
The written value | ✅ |
data.remove |
key |
null | ✅ |
data.has |
key |
Boolean | ✅ |
data.merge |
key · value (must be an object) |
null | ✅ |
data.keys |
— | Array of keys | ✅ |
data.getAll |
— | The whole object | ✅ |
data.clear |
— | null | ✅ |
data.* has no scope parameter; writing one is ignored silently. The data domain is isolated per package, and the host injects the identity.
For loading and toasts on a page, use the App-injected ui.showLoading / ui.toast below (they take text and a type), not the built-in showLoading / toast.
⚠️ The two toasts differ in both the parameter name and the accepted values; mixing them up is silent — you just get no styling:
| Parameter | Values | |
|---|---|---|
built-in toast |
style |
success / warning / error |
injected ui.toast |
type |
info (default) / success / error |
So ui.toast has no warning and the built-in toast has no info; putting style on ui.toast, or type on the built-in toast, is treated as if you had written nothing and falls back to the default style. In an event flow, always use ui.toast.
App-injected (event flows only)
| action | Inputs | Output | xpage | .xwidget event flow | Render flow |
|---|---|---|---|---|---|
xpage.const |
Any object; every key is written into scope | — | ✅ | ✅ | ❌ |
xpage.setState |
Object; replaces the page state wholesale | — | ✅ | no-op | ❌ |
xpage.patchState |
Object; merges incrementally | — | ✅ | no-op | ❌ |
xpage.redraw |
id |
— | ✅ | no-op | ❌ |
xpage.redrawPage |
— | — | ✅ | no-op | ❌ |
xpage.reloadPage |
— | — | ✅ | no-op | ❌ |
xpage.reenterPage |
— | — | ✅ | no-op | ❌ |
page.setResult |
Any object = the return value | — | ✅ (html pages too) | no-op | ❌ |
page.close |
— | — | ✅ | no-op | ❌ |
ui.showLoading |
text · progress (a decimal 0–1; omit it for the indeterminate spinner) |
— | ✅ | ✅ | ❌ |
ui.hideLoading |
— | — | ✅ | ✅ | ❌ |
ui.toast |
message (may be ${@i18n.k}) · type = info (default) / success / error · duration (milliseconds; platform default if omitted) |
— | ✅ | ✅ | ❌ |
ui.haptic |
type = tap (default) / impact / success / warning / error / selection |
— | ✅ | ✅ | ❌ |
ui.alert |
title · message |
— | ✅ | ✅ | ❌ |
ui.confirm |
title · message · okText · cancelText · destructive |
Boolean | ✅ | ✅ | ❌ |
ui.presentSheet |
source · params |
The child page's page.setResult value / null |
✅ | ✅ | ❌ |
ui.dismissKeyboard |
— | — | ✅ | no-op | ❌ |
input.focus / input.blur / input.clear / input.selectAll |
id |
— | ✅ | no-op | ❌ |
input.setValue |
id · value |
— | ✅ | no-op | ❌ |
nav.open |
url (required) · container (page default / sheet / dialog) · params · fallback |
One of the five below | ✅ | ✅ | ❌ |
nav.openForResult |
url · params |
The child page's result / null | ✅ | ✅ | ❌ |
startPageForResult |
page (required) · container (defaults to sheet) · params |
{ value, cancelled } |
✅ | ✅ | ❌ |
singleValue |
The whole params is the single-value config; must include container |
{ value: { value }, cancelled } |
✅ | ✅ | ❌ |
widget.updateParams |
The top-level object is the params to merge | { params } |
✅ | ✅ (onEdit only) |
❌ hard refusal |
widget.refresh |
scope (bundle default / widget / self) · widgetId · desktop |
{ refreshed } |
✅ | ✅ | ❌ hard refusal |
widget.pick |
items?: [{ id, params? }] |
{ listed } |
✅ | ✅ | ❌ |
installBundle |
id (required) · version? · ref? |
null | ✅ | ✅ | ❌ |
alert.add |
id (required, xJob/<id>.xjob in this package) · params? (pre-filled values the user can still change) |
a bare string ok / cancel / quota; if id is not found in this package (or that rule is not an alert) → the flow fails with rule_not_found and no panel opens |
✅ | ✅ | ❌ |
alert.skip |
id (required) · params? (omit for every instance of that rule) · until = today (default) or an ISO timestamp |
{ skipped }, plus { until } (the effective cut-off, in milliseconds) when at least one was skipped |
✅ | ✅ | ❌ |
alert.remove |
id (required) · params? (subset match: removed when the keys you give are equal; omit for every instance of the rule) |
{ removed }; no match / unknown rule = { removed: 0 }, not a failure. Removes reminders only, never background jobs, and opens no panel; needs minEngine ≥ 3 |
✅ | ✅ | ❌ |
"no-op" = callable but does nothing (there is no page to change on a widget host).
Names to stop writing
| If you see | Write instead |
|---|---|
inputValue |
singleValue (there is no alias; the old name simply resolves to no action) |
inputForm |
Open a form page with startPageForResult |
xpage.setResult |
page.setResult |
nav.back |
There is no such action; go back with page.close, or let the host's back gesture handle it |
operation nodes
| op | props | items | Notes |
|---|---|---|---|
if |
val |
Runs when the condition is true | The condition key is val, not cond |
for |
count · index |
Loop body | Fixed-count loop; index is the counter variable's name |
forEach |
items · key · index |
Loop body | Iterates an array; note that items inside props is the data being iterated |
set |
key · value |
— | Evaluates, then writes into scope |
remove |
key |
— | Deletes one key |
include |
dsl (another flow) |
— | ⚠️ Do not use: inside an .af / .df it always expands to nothing |
op:include is the one entry in this table that will not run: the flow engine does not implement its expansion, so that step always expands to zero child steps while the flow still reports success. To reuse shared steps there are exactly two routes: copy the steps, or split them into a separate .df and bind one more entry in depends (see the multiple depends in numable docs xwidget).
Two things you must remember:
- The condition key of
op:ifisprops.val. Writingcondmeans the condition is permanently false, the entire branch body silently never runs, the flow still reports success, and everything outside the branch works normally — only the writes inside the branch never happened (check G30). - Keys produced by
op:setinside anop:ifbranch are gone once the branch ends. Leaving the branch restores the outer bindings. So: compute values with a nestedif::expression, and useop:ifonly to dispatch actions.
events: where an .af can be attached
onClick / onEdit in .xwidget
events has exactly these two keys, written on the outer shell of .xwidget (not inside canvas). For field details see numable docs xwidget.
"events": {
"onClick": "/detail?secid=${secid}",
"onEdit": "/pick?ref=board&base=${base}"
}
If the value resolves to a string it is treated as a navigation string; if it is a structure (@[file://x.af] or an inline object) it runs as an ActionFlow. A bare flow filename is not allowed.
The four legal forms:
| Form | Example |
|---|---|
| Relative route path | "/detail?secid=${secid}" |
| Open this package's home page | "numable://self" |
| Open a page of this package | "numable://self/page/item?id=${itemId}" |
| af reference | "@[file://flow/mark-today.af]" |
- When
onEditnavigates it must be a bare route path (/pick), never thenumable://form — the App matches that string againstrouter.jsonverbatim, and a miss opens a blank page with no error (check G12d).onClickaccepts both forms. onEditmay target an html / xpage / form page, but you must be able to prove it can write back (check G12e).- The path base of the af form is
xWidget/. ${…}inside a navigation string only reads top-level scalars; the scope, from lowest to highest precedence: shell params → instance params → local data → thedependsresult set.
When a whole-widget onClick / onEdit runs an af, its inputs are that widget's instance params + @i18n (only this af file's own table) + @app — there is no @event. The widget was tapped as a whole; there is no "which element".
@event.* (XPage nodes / RCN cells / forms)
| Touchpoint | What you can read |
|---|---|
onClick / longClick / a menu item |
@event.id |
An input's onChange / onBlur / onSubmit |
@event.value |
onPageChange |
@event.page |
onSubmit of a .xform |
@event.value = { fieldName: value } |
dataSource of a searchSelect |
@event.keyword |
dataSource of a dynamicCascader |
@event.path / @event.level |
onLoad / onReachEnd |
No payload |
For how to read @i18n, see numable docs i18n.
The primitives you will use
widget.refresh — make a widget really fetch
{ "action": "widget.refresh", "params": { "scope": "bundle", "widgetId": "streak", "desktop": true } }
| scope | Reach | Notes |
|---|---|---|
self |
The one widget that triggered this flow | Needs the host to supply the widget-instance context |
widget |
Every instance of one widget in this package | widgetId required |
bundle |
Every widget in this package (default) | The one you want most of the time |
- The semantics are clear the fetch timer + really fetch + repaint, not reload. A reload would hit the render-image cache, which means nothing was refreshed at all.
- Returns
{ refreshed: <count> }. Matching 0 widgets is a success, not an error. - Called again inside the 5000 ms throttle window, it returns
{ refreshed: 0 }immediately. - It does nothing across packages; render flows refuse it outright; it is unavailable in
.df. - The errors (
no_brick_context/no_bundle_context/unknown_scope/widget_not_in_bundle) fail the whole flow. - You must call it after writing to disk, with every
data.setahead of it (manual review; therunlayer cannot see this, and the symptom is "I changed the setting and the widget didn't move").
widget.updateParams — write values back to the widget instance
{ "action": "widget.updateParams", "params": { "city": "${r.value.value}", "sub": null } }
- The top-level object is the params to merge; do not wrap it in another layer. Merged key by key: a value overwrites,
nulldeletes the key and falls back to the default in.xwidget. - Values must be scalars, and they are normalized to a standard form:
1.0→"1",true→"true". Passing an object or array errors immediately (non_scalar_value). - Returns
{ params }; a failure (no_brick_context/non_scalar_value/brick_not_found) fails the flow. - Only reachable from an
onEditaf. A whole-widgetonClickflow has no widget-instance context, so calling it there always fails. An html parameter page uses the bridge'sxbridge.updateParams(seenumable docs bridge); every other page type uses this action. - Render flows refuse it outright.
The usual chain: onEdit: "@[file://flow/edit.af]" → singleValue or startPageForResult collects one value → widget.updateParams writes it back. The whole thing needs no page of your own.
startPageForResult — open a page and wait for its value
{
"id": "picked",
"action": "startPageForResult",
"params": { "page": "/for-result-pick", "container": "sheet", "params": { "secid": "${secid}" } }
}
The output is always { value, cancelled }; read ${picked.value.xxx} and ${picked.cancelled}.
| container | Meaning | Height |
|---|---|---|
page |
Pushes onto the stack; with no stack, one container is stood up first, then pushed | Full |
sheet (default) |
Overlay pinned to the bottom of the container | 0.8 of the container height, fixed |
dialog |
Overlay centered in the container | 0.6 of the container height, fixed |
It handles all three page types: xpage / html / form. How a child page returns a value:
| What the child page did | Result |
|---|---|
page.setResult({...}) |
Closes and returns the value; if that page was opened by ordinary routing, it is a pure no-op |
page.close() |
Closes with cancelled: true; an ordinary routed page really does close |
| Nothing / the flow failed | The page stays mounted, the value is still there, and it can be submitted again |
The user pressing ✕ or ‹, tapping the scrim, or using the system back gesture is a cancel: it does not go through onSubmit, and the host delivers { value: null, cancelled: true } directly. Cancelling is a normal path — always test cancelled before you use value.
ui.confirm — a confirmation step that hands back a boolean
{ "id": "ok", "action": "ui.confirm",
"params": { "title": "Clear every record?", "message": "This cannot be undone", "okText": "Clear", "destructive": true } }
Feed ${ok} straight into an op:if afterwards:
{ "op": "if", "props": { "val": "${ok}" },
"items": [ { "action": "data.clear" }, { "action": "widget.refresh" } ] }
okis a real boolean, so put it directly inprops.val.- ⚠️ Do not convert it to a number first. The truthiness test behind
props.valis narrow: a boolean is itself, a string counts only as1/true/on/yes/y(case-insensitive), and a number is true only when it is exactly 1 (2and-1are both false). Code that computes a2and assumes "there is something there" walks silently into the false branch while the flow reports success. destructive: truecolours the confirm button red; use it for operations you cannot come back from, such as deletion.- It waits for the user, and that wait does not count against the event flow's 15-second timeout.
ui.alert — a one-button notice that waits to be dismissed
{ "action": "ui.alert", "params": { "title": "Already checked in today", "message": "Come back tomorrow" } }
It returns nothing. Use ui.toast to say something in passing (no interruption); reach for ui.alert only when the user has to acknowledge it before anything continues.
ui.presentSheet — raise a child page from the bottom
{ "id": "r", "action": "ui.presentSheet",
"params": { "source": "/detail", "params": { "id": "${id}" } } }
It returns exactly the object the child page passed to page.setResult, or null if the page was closed. It is the primitive underneath startPageForResult — write startPageForResult instead in normal code: its return value is normalized to { value, cancelled }, so you never have to guess whether a null meant "cancelled" or "returned nothing".
nav.openForResult — push a page onto the stack and wait for its value
{ "id": "picked", "action": "nav.openForResult",
"params": { "url": "/city-pick", "params": { "cur": "${city}" } } }
Also a primitive underneath startPageForResult, except that it always pushes onto the stack (equivalent to container: "page") and returns the bare result or null. New flows should always use startPageForResult; these two primitives remain for packages that already call them.
xpage.setState and patchState — change page data
Both take "the whole params object is the table"; there is no value wrapper:
{ "action": "xpage.patchState", "params": { "tab": "week", "loading": "0" } }
| Semantics | Where to use it | |
|---|---|---|
xpage.patchState |
Shallow merge: overwrites only the keys you wrote, leaving the rest untouched | Everyday use |
xpage.setState |
Wholesale replacement: keys you did not write are wiped | Only when resetting a whole page |
The most common mistake is using setState where patchState was meant: you change one tab value and clear every other key on the page, and the symptom is that unrelated content suddenly goes blank.
Once the state is changed, decide how much to repaint:
| action | Repaint scope | Does it re-run depends |
|---|---|---|
xpage.redraw |
Only the node named by params.id |
No |
xpage.redrawPage |
The whole page | No |
xpage.reloadPage |
The whole page, keeping page state and scroll position | Yes, and onLoad does not re-run |
xpage.reenterPage |
The same as closing and re-entering: state is cleared and the first frame is the skeleton | Yes, and onLoad re-runs too |
{ "action": "xpage.patchState", "params": { "tab": "week" } }
{ "action": "xpage.redraw", "params": { "id": "chart" } }
There is also xpage.const: it writes top-level variables into this flow's own scope (later steps read them with ${name}), not into page state, so nodes on the page cannot see them. Use patchState when the page has to see the value.
input.* — drive an input box imperatively
An input node's value normally writes itself into page state silently (the key is props.bindKey, defaulting to the node id). These five are for moving it from a flow:
{ "action": "input.focus", "params": { "id": "kw" } }
{ "action": "input.setValue", "params": { "id": "kw", "value": "${@i18n.preset}" } }
{ "action": "input.clear", "params": { "id": "kw" } }
{ "action": "input.selectAll", "params": { "id": "kw" } }
{ "action": "input.blur", "params": { "id": "kw" } }
idis the node id, not thebindKey.input.setValuealso updates the state thebindKeypoints at, exactly as if the user had typed it.- Where there is no input host (a widget's event flow), all five spin without effect and without an error.
- Dismissing the keyboard is a separate action,
ui.dismissKeyboard(no parameters); it does not touch the value:{ "action": "ui.dismissKeyboard" }.
widget.pick — open the "add to dashboard" panel
{ "action": "widget.pick",
"params": { "items": [ { "id": "today", "params": { "habit": "water" } }, { "id": "streak" } ] } }
- Omitting
itemslists every widget in this package; supplying it lists only those, each with its own instance parameters. idis the file name ofxWidget/<name>.xwidget(without the extension).- If not one id matches, this step fails (
no_matching_widget); it does not fall back to "list them all". So when you rename a widget file, remember to update this too. - Returns
{ listed: <how many were listed> }. - ⚠️ The write happens on the button inside the panel, not in this step. After calling it, do not say "Added" — the user may well browse and close (
check G24).
installBundle — propose installing another package
{ "action": "installBundle", "params": { "id": "01M050AARQ0R08T9EHGDSGZBHJ" } }
id is required (the target package's ULID); version / ref are optional. All it does is raise the install panel; whether anything gets installed is the user's decision inside that panel, and the flow never learns the outcome (the return value is always null). As with widget.pick: do not say "Installed" after calling it.
page.close — close this page
{ "action": "page.close" }
No parameters. On a page opened by ordinary routing it really closes the page; on a child page opened by startPageForResult it dismisses the overlay and delivers { value: null, cancelled: true }. To close with a value use page.setResult, which closes the page itself — no extra page.close needed.
Form input
Use singleValue for one value; collect several fields at once with a .xform page.
{
"id": "r",
"action": "singleValue",
"params": {
"container": "page",
"title": "Pick from a list",
"desc": "Subtitle",
"confirmTxt": "OK",
"component": {
"type": "select",
"value": "b",
"props": { "items": [ { "label": "Option A", "value": "a" }, { "label": "Option B", "value": "b" } ] }
}
}
}
container is required (page / sheet / dialog). It returns { value: { value: <string | array | null> }, cancelled }, so the value lives at ${r.value.value} — one layer deeper than you'd expect.
A .xform is a page of type form, opened with startPageForResult, and it does not write its own container (the caller decides which of the three). The keys of form are the result keys, and declaration order is render order; the flow bound to onSubmit sees only @event.value. For the full field-type table, props, and value shapes, see numable docs params.
nav.open and jumping to an external App
{
"id": "r",
"action": "nav.open",
"params": { "url": "futunn://quote/00700", "fallback": "https://www.futunn.com/stock/00700-HK" }
}
The order of decisions: dangerous-scheme denylist (javascript / file / data / intent / about / blob, check G16) → gesture gate (anything outside the 10-second window after a real user gesture is refused silently, without even a dialog) → this package's remembered authorization for that scheme → the first-jump confirmation dialog. Authorization is remembered per (package, scheme), never enters a backup, and is cleared on uninstall.
Five possible return values: opened · no-handler · denied · fallback · presented (returned immediately, without waiting, when container is sheet or dialog).
fallbackis optional and only accepts http(s) (check G16); it fires only for an external App withno-handler. A user refusal (denied) never falls back.nav.openwaits for the user and is marked interactive; a waiting action that is not marked interactive will hit the event flow's hard 15-second timeout.- Any
${…}spliced into the URL after the scheme needs$[urlEncode::(…)]around it (check G16, warning level). numable://selfdoes not work insidenav.open— it round-trips through the system scheme, and the receiving end has no "which package am I" context, so you get "the tool 'self' is not installed".selfis only valid in in-page routing; to open your own package from a flow, write this package's ULID literally:{"action":"nav.open","params":{"url":"numable://01M050AARQ0R08T9EHGDSGZBHJ"}}.
Rules (break one = rework)
| Rule | Checked by | Symptom when broken | Fix |
|---|---|---|---|
The condition key of op:if must be props.val |
check G30 |
Condition always false, branch silently skipped, flow still reports success | Change cond to val |
The step right after concurrent must not reference a concurrent branch's id |
check G4b |
Concurrent results land one beat late, those fields silently read empty, and the widget is all -- |
Insert a barrier in between: {"op":"set","props":{"key":"_b","value":"1"}} |
A parseDate pattern must contain no letters once its tokens are removed |
check G4c |
On phones parsing fails entirely and returns null, producing absurd day counts | subString:: the date portion out first, then parseDate:: |
An onEdit af must be an .af inside the package, must not contain .., and the file must exist (base xWidget/); if it navigates it must be a bare path present in router.json |
check G12d |
"Tapping does nothing" / a blank page opens with no error | Add the file, or change numable://… to /pick |
An onEdit af must contain widget.updateParams, and the target page must be able to write back |
check G12e |
It opens, you press, and nothing whatsoever changes | Add the write-back to the af; a form page writes back in onSubmit, an xpage in a node's events |
Dangerous schemes are banned; fallback accepts only http(s); ${} inside a third-party scheme URL needs urlEncode |
check G16 |
The App refuses the jump / a parameter with spaces or special characters lands somewhere wrong | See the previous section |
In an .af bound to a cell event, actions[0] should be ui.haptic |
check G22 (warning) |
About 250 ms of no feedback between the press and the screen changing, so the user taps again | Move ui.haptic to the front |
An .af that writes to disk (data.set / data.merge / data.remove) must contain widget.refresh, with every write ahead of it |
check G37 (warning) |
It saved, but the widget still shows the old value until the next natural refresh | Append widget.refresh at the end of the flow; a page that refreshes itself with xpage.reloadPage or widget.updateParams does not count as a violation |
Do not use op:include |
Manual review | That step always expands to nothing while the flow still reports success | Copy the steps over, or split them into a separate .df and bind one more depends |
The parameter of sleep is named timestamp, with a string value |
Manual review · check G52 (a number) |
No wait at all; it runs straight on; as a number, the step does not run on HarmonyOS | Rename the parameter; quote the number |
Arrays must be real array literals, landed with op:set before being referenced |
Manual review | Method arguments cannot read it and get nothing | Write {"op":"set","props":{"key":"list","value":[…]}} first |
Keys set by op:set inside an op:if branch are invisible outside it |
Manual review | Reading that key outside the branch is always empty | Compute values with a nested if:: expression; use op:if only to dispatch actions |
| Apart from actions that wait for the user, event flows have a hard 15-second timeout | run layer | The flow is cut off halfway and the later writes never happen | Split the flow, or move the slow part into .df |
Composite steps go in the action slot |
run layer | Written as op, the whole block is skipped silently |
{"action":"concurrent","items":[…]} |
data.get must have a default |
run layer | The key never enters scope and every downstream ${k} is empty with no error |
Add "default": "" |
When something goes wrong
| Symptom | Most likely cause | Do this first |
|---|---|---|
| Tapping a widget or button does nothing at all | The af path base is wrong (the base for .xwidget events is xWidget/, not the package root), or the extension is wrong |
Run numable check and look at G12d; confirm the file really is under xWidget/flow/ |
| The parameter page opens, you fill it in and save, and the widget is unchanged | There is no widget.updateParams on that path, or it sits inside a whole-widget onClick (no widget instance available) |
Run numable check and look at G12e; move the write-back into the onEdit af or the parameter page |
| The data was saved but the widget is still stale | The flow is missing widget.refresh at the end, or it sits ahead of data.set |
Move widget.refresh after every write |
| The dialog or form appeared, but the flow died right after it | The event flow's hard 15-second timeout — slow actions piled up around the step that waits for the user | Move fetching into .df and keep only interaction and writes in the event flow |
| One branch never runs and the log is clean | op:if was written with cond |
Change it to props.val and run numable check for G30 |
| Every field after a concurrent block is empty | The concurrent results land one beat late |
Insert a barrier op:set, or fall back to sequential |
Some fields on the widget are permanently -- |
depends was written as a bare @[file://…], so the inputs were swallowed into nothing |
Change it to {"flow":…,"params":{…}} |
For more silent failures, see numable docs pitfalls.
See also
numable docs df— the data flow's allowlist and syntaxnumable docs xwidget— whereeventsattaches, and where instance params come fromnumable docs params— the full form field-type table and.xform