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

df — the data flow (.df)

Audience: people building a tool, and the AI working on their behalf. Both read this same page.

What it is

A .df is the data-fetching step of one widget or one page: call the network, pull fields out, compute derived values, and finally expose the keys the rendering layer needs. It only fetches and transforms — no UI, no navigation, no user involvement — because it runs on the rendering path, possibly at moments the user cannot see (a scheduled home-screen widget refresh, the dashboard's first frame).

Anything that needs the user's finger (a confirmation, opening a page, writing parameters back, a haptic tap) belongs to the other file type, .af. The two are two capability tiers of the same engine:

.df data flow .af action flow
Where it is used canvas.depends in .xwidget, depends on an XPage node, banner flows events.* (tapping a widget, tapping a node, submitting a form)
What it may use an allowlisted subset (below) everything, including ui.* / nav.* / widget.*
Who triggers it the platform (schedule, first frame, pull to refresh) the user

Getting the file type wrong has a very specific symptom: call an .af with runDataFlow from H5 and the app answers -6 flow not found. For how to write .af and the full action list, see numable docs af.

A minimal working example

A flow that really calls the network, really fails, and really exposes keys:

{
  "version": 1,
  "actions": [
    {
      "id": "resp",
      "action": "request",
      "params": {
        "url": "https://hn.algolia.com/api/v1/search?tags=front_page",
        "method": "GET",
        "formatType": "json",
        "timeout": "8000"
      }
    },
    {
      "op": "set",
      "props": { "key": "_b", "value": "1" },
      "_note": "Barrier: the step immediately after request/concurrent cannot see the result; put one empty set in between"
    },
    {
      "op": "if",
      "props": { "val": "$[if::(gt::(length::(${resp.hits}),0),0,1)]" },
      "items": [ { "action": "error", "params": { "errorMsg": "Could not load the leaderboard" } } ],
      "_note": "Essential field missing, so fail the flow: the platform falls back to the last good data. Never render an empty payload"
    },
    { "op": "set", "props": { "key": "t0", "value": "${resp.hits[0].title}" } },
    { "op": "set", "props": { "key": "p0", "value": "${resp.hits[0].points}" } },
    { "op": "set", "props": { "key": "id0", "value": "${resp.hits[0].objectID}" } },
    { "op": "set", "props": { "key": "at", "value": "$[formatDate::(${@time.nowMs},HH:mm)]" } },
    { "op": "set", "props": { "key": "hasT0", "value": "$[if::(gt::(length::(${t0}),0),1,0)]" } },
    { "action": "resultFilter", "params": { "keys": ["t0", "p0", "id0", "at", "hasT0"] } }
  ]
}

Run it:

numable run <package> --flow top1          # the name = the widget name (the .xwidget filename); for a page flow use the filename (e.g. rates) or page/rates

How to write it

File structure

Two top-level keys are required: { "version": 1, "actions": [ … ] }. Optionally i18n (the file's own content table, same shape as in an .af, referenced from the body as ${@i18n.key} — see "Fetching by language and producing text" below). A _note string can be added anywhere as a comment — keys starting with an underscore are always ignored by the runtime.

The three shapes of a step

Shape How to write it Notes
action step { "id": "resp", "action": "request", "params": {…} } a step that does work. id = the key its result lands under
operation step { "op": "set", "props": { "key": "x", "value": … } } control flow and assignment, keyed by op, children in items
composite step { "action": "concurrent", "items": [ … ] } written in the action slot, not the op slot

Writing concurrent / sequential as {"op": "concurrent"} makes the whole block be skipped silently — the symptom is that the section "succeeds" in 3 milliseconds with every field empty.

How to write concurrency

Two independent requests run side by side, and the total time is the slower of the two:

{ "action": "concurrent", "items": [
    { "id": "a", "action": "request", "params": { "url": "https://api.example.com/a", "formatType": "json" } },
    { "id": "b", "action": "request", "params": { "url": "https://api.example.com/b", "formatType": "json" } } ] }

Put a barrier right after it, and only then start using ${a.…} / ${b.…}:

{ "op": "set", "props": { "key": "_b", "value": "1" } }

id is the result key

When a step with an id finishes, its result is written into the data scope under that id, and later steps read it with ${id.field}. Two rules:

The actions you may use (allowlist; anything else fails)

Group action
network and parsing request · clearCookie · htmlParse · xmlParse
this package's storage data.get · data.set · data.remove · data.has · data.keys · data.getAll · data.merge · data.clear
flow control cancel · error · finish · sleep · log · resultFilter
composite sequential · concurrent (in the action slot)
operation (keyed by op) if · for · forEach · set · remove · include (⚠️ see below)

Explicitly forbidden: toast / showLoading / hideLoading, plus every app-injected action (ui.* / nav.* / xpage.* / widget.* / installBundle). Those may appear only in an .af; numable check catches it (E).

Methods used inside expressions (things like $[calc::(…)]) are a separate table; see numable docs methods for the full list. Look a method up before you write its name: an unregistered method silently evaluates to empty, the flow still reports success, and the number after that symbol on the widget is simply gone.

Every parameter of request

Parameter Type Notes
url string required. May contain ${}. The host must be listed in manifest.network
method string GET (default) / POST / PUT / PATCH / DELETE
queryParams object the parameters after the question mark, written as {"page":"1"} — no manual string building or escaping. Write every value as a string, numbers in quotes too (check G52)
header object custom request headers, values always strings (check G52). Do not put secrets here; use credential
formData array multipart form upload, honoured only when method is POST. If you fill this in, do not also fill in body
body string the request body; it only accepts a string. To send JSON, write the JSON string here
formatType string string (default, the raw text) / json / xml / base64 (the bytes as a base64 string) / tsv / csv (a table turned into an array of objects). Anything outside those six is not an error — it falls back to "try JSON, then XML, then hand back the raw text". Case does not matter ("JSON" still matches), but a value like "text" takes that guessing path: it usually still produces something, at the cost of losing the one signal that tells you whether this endpoint really returns JSON; see below
columns array tsv / csv only: keep just these columns (by header name), in the order you list them; a column missing from the header comes out as an empty string. A comma-separated string also works. Omitted, an empty array or an empty string = keep every column
credential string the credential declaration id, a literal; see below
timeout string milliseconds, written as a string "8000", usually 5000–10000. Leave it out and the request waits forever, so a slow endpoint holds up the widget's whole first frame. As a number it runs on iOS and the web but the whole request is never sent on HarmonyOS (check G52)

GET: parameters go in queryParams

{ "id": "resp", "action": "request", "params": {
    "url": "https://api.example.com/v1/quote",
    "method": "GET",
    "queryParams": { "symbol": "${sym}", "range": "1d" },
    "formatType": "json",
    "timeout": "8000" } }

POST: body or formData, not both

{ "id": "resp", "action": "request", "params": {
    "url": "https://api.example.com/graphql",
    "method": "POST",
    "header": { "Content-Type": "application/json" },
    "body": "{\"query\":\"{ viewer { login } }\"}",
    "formatType": "json",
    "timeout": "8000" } }

The six values of formatType

Value What you get When to use it
string (default) the raw response text when you hand it to htmlParse / xmlParse next, or cut it up yourself with split::
json the parsed object/array; read ${resp.a.b[0].c} directly the endpoint returns JSON
xml { rootTag: {…} }: attributes become @attr keys, node text becomes a value key, same-named siblings become an array shallow XML
base64 the base64 of the raw bytes as a string for binary payloads (image bytes), not for text
tsv / csv an array of objects: the first row is the header, one object per row, every value is a string the endpoint returns a tab- or comma-separated table (report downloads and the like)

Referencing a credential

{ "id": "me", "action": "request", "params": {
    "url": "https://api.github.com/user", "formatType": "json", "credential": "gh" } }

The value of credential must be a literal id declared in manifest.credentials, never a ${} expression — a dynamic value is silently passed through at runtime and the request goes out anonymous, which reaches you as a 401 or as empty data. For the declaration format see numable docs layout; keys for local testing go in .numable/params/_credentials.json.

The secret itself never appears in the flow: injection happens at the app's network boundary. Also never put a credential or anything derived from it into resultFilter — once exposed it lands in the render cache and in shared screenshots. To let RCN know whether a credential is bound, expose a hasToken flag instead.

Scraping a page: htmlParse / xmlParse

When there is no API, scrape the page. Fetch the raw text with formatType: "string" and hand it to htmlParse:

{ "id": "page", "action": "htmlParse", "params": {
    "content": "${resp}",
    "rules": [
      { "key": "title", "selector": "h1.site-title", "defaultValue": "" },
      { "key": "posts", "selector": "ul.list > li", "isArray": "true",
        "items": [
          { "key": "name", "selector": "a.t" },
          { "key": "url", "selector": "a.t", "extractFrom": "href", "prefix": "https://example.com" },
          { "key": "hot", "selector": "span.n", "regex": "(\\d+)", "defaultValue": "0" }
        ] } ] } }

rules is an array, not an object — write it as an object like {"title": "h1"} and not a single key comes out, with no error. Each entry is one field:

Field Required Notes
key yes the field name in the result. A rule without a key is skipped entirely
selector yes treated as XPath when it starts with /, ./ or (; anything else is a CSS selector
isArray "true" = keep every matching node, result is an array; by default only the first is taken
extractFrom text (default, the text with whitespace trimmed) / html (the inner HTML) / any other value is taken as an attribute name (href, src, data-id)
regex filters the extracted string once more, taking capture group 1 (or the whole match if there is no group); no match = the value counts as empty
prefix / suffix glued before and after the extracted value, e.g. to complete a relative link
defaultValue used when no node matches or the value is empty
items an array of sub-rules that turns each matching node into an object. With items present, extractFrom / regex / prefix / suffix on that same rule all have no effect

Three traps:

Structured op:set

{ "op": "set", "props": { "key": "rows", "value": ["${v1}", "${v2}", "${v3}"] } }

value may be an array or object literal: the structure is preserved and the leaves are evaluated one by one. An array destined for sum:: / max:: has to be built this way — writing [...] inside a method argument string does not work, and that statistic silently disappears.

op:if and the two loops

{ "op": "if", "props": { "val": "$[eq::(${fresh},0)]" }, "items": [ … ] }

The condition key of op:if is props.val. Writing cond raises no error: the condition is permanently false, the branch body silently never runs, and the flow still reports success.

Use op:if only to dispatch actions, not to compute values: a key set by op:set inside a branch is unreachable outside it. Compute values with a nested if:: inside an expression.

For a fixed number of rounds, use op:for:

{ "op": "for", "props": { "count": "5", "index": "i" },
  "items": [ { "op": "set", "props": { "key": "y_${i}", "value": "$[calc::(48+${i}*48)]" } } ] }

To walk a collection, use op:forEach:

{ "op": "forEach", "props": { "items": "${resp.list}", "key": "it", "index": "i" },
  "items": [ { "op": "set", "props": { "key": "n_${i}", "value": "${it.name}" } } ] }
op:for op:forEach
Rounds count rounds (truncated; ≤0 runs not once) one per element of items
Loop variables the index only key = the current element, index = the position
Name of the index variable index, and __index when you leave it out same

resultFilter decides what the rendering layer can see

{ "action": "resultFilter", "params": { "keys": ["t0", "p0", "id0", "at", "hasT0"] } }

Only the keys listed here reach RCN / XPage. Miss one and that slot on the widget is empty with no error (check G28 compares every ${} in the RCN against this key list). Two rules ride along:

data.*: this package's persistence

Reads and writes this package's private sandbox (the user's watchlist, the last selected item, the first-paint cache), preserved across launches. One line per action, eight of them:

{ "id": "saved",   "action": "data.get",    "params": { "key": "cities", "default": [ { "name": "Shanghai" } ] } },
{ "id": "hasCity", "action": "data.has",    "params": { "key": "cities" } },
{ "id": "allKeys", "action": "data.keys",   "params": {} },
{ "id": "all",     "action": "data.getAll", "params": {} },
{ "action": "data.set",    "params": { "key": "cityIdx", "value": "2" } },
{ "action": "data.merge",  "params": { "key": "prefs", "value": { "unit": "c", "sort": "hot" } } },
{ "action": "data.remove", "params": { "key": "c.wx.31_121" } },
{ "action": "data.clear",  "params": {} }
action Parameters Result
data.get key · default the stored value; default when nothing was stored
data.set key · value echoes back the value just written
data.has key true / false
data.keys — an array of every key in this package
data.getAll — an object with every key and value in this package
data.merge key · value (object) no result
data.remove key no result
data.clear — no result

Key points:

The first-paint cache (for pages) and the ${_cache} switch

A .df consumed by a page should implement three states, otherwise every visit waits a full network round trip for nothing:

  1. cache present and fresh → use it, do not even open a connection;
  2. cache present but stale → render it anyway, and expose a _stale flag so the page can revalidate in the background;
  3. no cache / far too old → actually fetch.
{ "op": "set", "props": { "key": "cacheIn", "value": "${_cache}" } },
{ "op": "set", "props": { "key": "useCache", "value": "$[parseNumber::(${cacheIn},0)]" } },
{ "op": "set", "props": { "key": "ckey", "value": "$[connect::(c.wx.,${lat},_,${lon})]" } },
{ "id": "hit", "action": "data.get", "params": { "key": "${ckey}", "default": {} } }

Three hard requirements:

The six rules you must keep

  1. The barrier: the step immediately after request / concurrent / data.get cannot see the result (it lands one beat later), and so is the first node of a loop body (it reads the snapshot taken as the loop was entered). Put a {"op":"set","props":{"key":"_b","value":"1"}} in between. Likewise, do not read the id of a concurrent branch from inside an op:if scope — the evaluation scopes do not line up and you get empty.
  2. A failed fetch must fail the flow. If an essential field is missing, go to error and the platform falls back to the last good data; reporting success and rendering an empty payload overwrites good data. Pin the test on the field "without which this widget is pointless"; an empty collection is a success, and a broken optional branch does not throw.
  3. Always express emptiness as an explicit flag: hasX = $[if::(gt::(length::(${rawString}),0),1,0)], and the rendering layer looks only at hasX. Do not test emptiness with eq:: — if both sides convert to numbers it compares numerically, so eq::("00","") and eq::(empty,0) are both true. Pin length:: to a raw string field; on a number you computed yourself it is always 0.
  4. A ${} inside a method argument cannot see the flow's input parameters, only keys already landed in the flow (results of actions with an id, keys from op:set). So the first thing an input parameter does in a flow is land: {"op":"set","props":{"key":"k","value":"$[findNotEmpty::(${pair},btcusd)]"}}.
  5. A key referenced by op:set must appear before it. The engine runs in order and has no dependency graph; get the order wrong and the branch always falls to else while the code looks perfectly correct.
  6. Guard the time anchor against empty. A live number has to tell people how old it is; feed formatDate:: an empty string and it renders 1970. Put a sentinel in front: $[if::(eq::(findNotEmpty::(${ts},__none__),__none__),,formatDate::(${ts},HH:mm))].

Fetching by language and producing text

A .df may read ${@app.language} directly (and @device.language / @time.locale); fetching by language is a legitimate, expected pattern (news headlines, city names, store descriptions). When the user switches language, the dashboard runs one widget.refresh per package (clear the fetch timer + a real fetch), so language-dependent data follows.

A .df has exactly the same shape as an .af: it may carry its own top-level i18n table (a flat { locale: { key: text } }), the body writes ${@i18n.key}, and the flow's input carries the host page's table ⊕ this file's table (plus @app). So finished sentences such as "About the same as yesterday" or "59% into today's range" can be produced right in the data layer:

{
  "version": 1,
  "actions": [
    { "id": "resp", "action": "request", "params": { "url": "https://api.example.com/today?lang=${@app.language}", "formatType": "json" } },
    { "op": "set", "props": { "key": "_b", "value": "1" } },
    { "op": "set", "props": { "key": "delta", "value": "${resp.delta}" } },
    { "op": "set", "props": { "key": "summary", "value": "$[if::(gt::(${delta},0),${@i18n.up},${@i18n.flat})]" } },
    { "action": "resultFilter", "params": { "keys": ["delta", "summary"] } }
  ],
  "i18n": {
    "zh-CN": { "up": "比昨天高", "flat": "和昨天差不多" },
    "en-US": { "up": "Higher than yesterday", "flat": "About the same as yesterday" }
  }
}

One sentence to remember: any flow file may have a top-level i18n table; write text as ${@i18n.key} and branch on language with ${@app.language}. Whether a plain word like "today" lives in the data layer or the rendering layer is your call — assembling it in the .rcn lets one set of data serve both languages, producing it in the .df saves the .rcn a join. numable check applies the same checks to a .df table as to an .af (G8: every referenced key must exist in the required locales, key sets match across locales, no unused keys).

The data-cache key includes the language: after a switch, no widget has stored data for the new language yet — a skeleton shows first, then the data once fetched; switching language offline leaves no data to show for that language (the empty or error state appears). That is a deliberate trade-off. The theme (light/dark) is different: it is a pure render parameter, switching theme only re-renders and never fetches, and the data cache is not split by theme either. The full rules for content tables are in numable docs i18n.

Rules (breaking one means rework)

Rule How it is checked Symptom when broken Fix
A widget's .df containing request or concurrent must have an error exit check G26 on a failed fetch the flow reports success and empty data overwrites good data test the essential field for emptiness → error
The step immediately after concurrent must not reference a concurrent branch's id check G4b those fields are all empty and the flow still succeeds insert an op:set _b barrier
The condition key of op:if must be props.val check G30 the condition is permanently false, the branch silently never runs, the flow still succeeds change it to props.val
A parseDate pattern must have no letters left once the tokens are removed check G4c parsing fails outright on phones and returns null; only the image from render looks fine cut off the T/Z with subString:: before parsing
request.credential must be a declared literal declId check G18b at runtime it silently degrades to an anonymous request, yielding a 401 or empty data use an id from manifest.credentials
The request hosts and manifest.network match exactly check G3 the network guard silently blocks it on a real device, or the install panel lists domains that are never used no more, no fewer
No doubled unit suffix in an expression (14.0ptpt), and no $[ nested inside $[…] check G28 the whole widget fails to render, with no error write the inner one as a bare method name, if::(…)
Every ${x} used in RCN is in the resultFilter keys of some .df check G28 that slot renders empty or is permanently on its fallback add it to keys
A .df consumed by a page that has a request should have a data.get cache check --profile publish G23 every visit waits a full network round trip for nothing implement the three states; if you skip it on purpose, write _lintCache: "exempt" plus a _note explaining why
In a .df consumed by a widget, the cache write-back must be controlled by ${_cache} and default to off check --profile publish G23 the widget's whole refresh chain spins for nothing and the data is stuck at last time pages pass _cache:1, the .xwidget passes nothing
The cached value must contain no credential; the cache key must contain no index check --profile publish G23 a secret lands in the cache; A's data is rendered for B expose flags only; key on stable identity
Every ${@i18n.key} referenced in a .df exists in the required locales of this file's (or the host page's) table check G8 one piece of that sentence is missing, with no error add the key; declare deliberate blanks in i18nEmptyOk
Never use an unregistered method name at the run layer (that key silently evaluates to empty) the "+%" style symbol is still on the widget but the number is gone look it up in numable docs methods first
concurrent / sequential go in the action slot at the run layer (the block succeeds in 3ms with every field empty) the whole block is silently skipped change it to "action": "concurrent"
The parameter of sleep is named timestamp, with a string value "1500" at the run layer (it simply does not wait) · check G52 (a number) throttling / backoff never takes effect; as a number, the step does not run on HarmonyOS rename it; quote the number
The rules of htmlParse / xmlParse are written as an object at the run layer (the result object is empty) not a single field comes out, and the flow still succeeds make it an array: [{"key":…,"selector":…}]
formData is only ever used with POST at the run layer (the server answers 400) the form was never sent and the flow raised nothing switch to POST, or use body instead
Never use op:include manual review that section runs not a single step, and the flow still succeeds copy the steps, or split them into their own .df
No branch that is allowed to fail goes inside a concurrent at the run layer (the other branches get no result either) one branch dies and the whole group has no data move it out of the concurrent and run it sequentially

When something goes wrong

Symptom Most likely cause What to do first
run succeeds but every field is empty the value path is off by one level numable run <package> --full to see the real response shape
the key right after request is null the result lands one beat later insert a barrier op:set
a loop meant to pick the first match picks the last each round's first node reads the pre-loop snapshot, so the guard flag is stuck at its initial value put a barrier op:set _lb as the body's first line
a statistic key vanished into thin air the method name does not exist, or [...] was written inside a method argument string check the method table; build arrays with a structured set
a branch never runs, yet the flow succeeds op:if was written with cond change it to props.val
a fake 0, 0 items, or 1970-01-01 shows on the widget an empty value was aggregated or formatted into a legal one test for presence before computing; wrap the time anchor in a sentinel
the whole widget is -- on exactly one platform xmlParse uses a different engine per platform; or the parseDate pattern contains literals switch to formatType:"string" + split::; keep the pattern pure tokens
pull to refresh on a page changes nothing it hit the cache again in onRefresh, data.remove the cache key before reloadPage; the key must match the one written in the flow character for character
the widget refreshed but the data is old the widget's .df cache is not controlled by ${_cache} add the switch, defaulting to off
requests 401 / return anonymous data credential is undeclared or written as an expression check it against manifest.credentials
htmlParse returns an empty object rules was written as an object, or the selector only holds after scripts render make it an array; go find the endpoint that page calls
after formatType: "json" the whole resp does not exist the response was not valid JSON, parsing failed and gave null switch to string and print the raw text

See also

numable docs methods · numable docs builtins · numable docs af