Numable authoring docsthe same content is available in the terminal: `numable docs <topic>`中文

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:

  1. The condition key of op:if is props.val. Writing cond means 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).
  2. Keys produced by op:set inside an op:if branch are gone once the branch ends. Leaving the branch restores the outer bindings. So: compute values with a nested if:: expression, and use op:if only 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 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

widget.updateParams — write values back to the widget instance

{ "action": "widget.updateParams", "params": { "city": "${r.value.value}", "sub": null } }

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" } ] }

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" } }

widget.pick — open the "add to dashboard" panel

{ "action": "widget.pick",
  "params": { "items": [ { "id": "today", "params": { "habit": "water" } }, { "id": "streak" } ] } }

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).

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