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

layout — package structure and manifest

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

What it is

A tool (an XBundle package) is a folder. Inside it there is a manifest.json (identity and metadata), some widgets (xWidget/), optional pages (page/) and assets. At publish time the whole folder is sealed into a .xbundle: the file set in the archive is manifest.files ∪ manifest.json, not one byte more. So "what is in the folder" = "what lands on the user's device".

The folder layout and the file extensions are hard conventions: the client picks a parsing engine by extension and looks for page/router.json and xWidget/*.xwidget at fixed paths. A file in the wrong place raises no error — it is simply never loaded.

manifest.json is everything this package tells the platform: what it is called, which category it belongs to, which hosts it needs, whether it needs the user's key, and the minimum engine it requires. The install panel, the store widget and the network guard read only this file; getting it wrong usually does not crash anything, it means the package installs, runs, and yet one particular thing never happens (for example, an undeclared host means the request is silently blocked).

Minimal working example

This is exactly what numable init produces, ready to copy and edit:

{
  "id": "01KZ58G6AXEKVWCQFYNY9S6EEM",
  "version": 1,
  "title": "我的工具",
  "lang": "zh-CN",
  "category": "dashboard",
  "subtitle": "从一张时间卡开始",
  "domain": "01KZ58G6AXEKVWCQFYNY9S6EEM",
  "minEngine": "1.0.0",
  "network": [],
  "i18n": {
    "en-US": { "title": "My Tool", "subtitle": "Start from a clock widget" }
  }
}

And the folder it goes with:

<package folder>/
  manifest.json              identity and metadata (required)
  logo.png                   512×512 full-bleed square (required to publish)
  banner.xbanner             banner at the top of the Tools page (optional)
  page/
    router.json              page route table (only needed if there are pages)
    html/<route>/index.html  H5 page
    xpage/<name>.xpage       XPage page (declarative, no WebView)
    form/<name>.xform        form page
    rc/<name>.rcn            page-scope RCN
    flow/<name>.af|.df       page-scope flows: .af interaction / .df fetching
    assets/                  images and text assets used by pages
  xWidget/
    <name>.xwidget           widget declaration, one file per widget
    rc/<name>.rcn            the widget's drawing
    flow/<name>.df           the widget's data flow
    assets/                  assets used by widgets
  .numable/                  local sidecar: fixtures and render output, never in the package
  .gitignore                 written by init (.numable/ .studio/ build/)

How to write it

Extensions are a hard convention

Extension What it is Where it goes
.xwidget The declaration of one widget One level under xWidget/, no nested folders
.rcn The drawing of a widget or page block (RCN template) xWidget/rc/ or page/rc/
.df Data flow (fetching and shaping, no UI) xWidget/flow/ or page/flow/
.af Action flow (something the user's finger takes part in) Same as above
.xpage XPage page page/xpage/
.xform Form page page/form/
.xbanner Tools-page banner (a widget's canvas: source / depends / refresh) Package root

The widget scope xWidget/ and the page scope page/ each have their own rc/ + flow/, physically separated; the @[file://…] base differs between them too (see numable docs xwidget).

manifest fields

Field Required Type Values / one thing to watch
id yes string A 26-character ULID (0-9A-HJKMNP-TV-Z). Generated by numable init, never changed
version yes int Content version of the whole package. Bump it whenever the content changes — distribution updates key off it alone
title yes string Package name. The bare value is in the lang language; ≤16 characters recommended, hard limit width 24 (full-width counts 2)
subtitle required to publish string One-line pitch, ≤22 characters recommended. Shown in the store and the install panel
description no string A short paragraph. It is the widget subtitle when someone shares this tool, and without it that subtitle falls back to the category name; can be overridden via i18n
category yes string A platform enum key, see below
domain yes string Business domain key. init fills it in; leave it alone
minEngine yes string Write the lowest level that covers the features you use: "1.0.0" for the basics; at least "2.0.0" with alerts / background jobs; at least "3.0.0" with one-time alerts, alert.remove, or tabular data (tsv / csv); at least "4.0.0" with a decision recipe task.then.recipe. numable init --job raises it for you, and check stops a value that is too low with G45 / G51. Only the major integer is compared, and it is a separate axis from the app version; numable doctor compares it against the current engine major
lang no (defaults to zh-CN) BCP-47 The package's base language — a statement of "which language every bare field is in". Write it explicitly
network required once you make requests string[] Outbound host allowlist, bare hosts with no protocol or path; *.example.com is supported (subdomains only, not the apex). Absent / empty array = everything outbound is refused. Redirects are checked hop by hop: every hop's host is tested against this allowlist again, and the hop that leaves it is refused (network_blocked:redirect_escaped:<host>); numable run / render apply the same rule as the app
credentials no object[] Declaration of user-supplied keys, see below
i18n no object Metadata translations (table B), see below
system no bool Reserved for platform system packages; do not write it in your own
keyId / files / signature — — Written automatically at publish time; they must not appear in the source folder

The 11 category enum values

finance · developer · productivity · life · health · system · tech · news · tools · dashboard · testing.

The platform ships Chinese and English wording for all 11 keys, so writing an enum key gets you both languages for free. tools displays the same name as productivity; use productivity for new packages. Free-form text outside the enum also installs, but then you have to supply an English override in i18n yourself, or an English environment shows Chinese.

credentials (user-supplied keys)

"credentials": [
  {
    "id": "gh",
    "type": "token",
    "required": false,
    "hosts": ["api.github.com"],
    "label": "GitHub 访问令牌",
    "i18n": { "en-US": { "label": "GitHub token" } },
    "help": "https://github.com/settings/tokens"
  }
]
Key Notes
id The name a data flow references from request.credential as a literal; unique within the package
type bearer / token / header (needs headerName) / query (needs paramName; use a header if you can)
required false = send the request anonymously when nothing is bound; true = do not send it at all and render a "credential needed" placeholder
hosts The key is only sent to these hosts; must be a subset of network
label The name shown in the binding panel. Both languages are mandatory: the bare value plus i18n["en-US"].label
help Where the user gets the key; must be an https link

The key itself never enters the package, never enters params, never enters data.*. For local testing put the value in .numable/params/_credentials.json ({"gh":"ghp_…"}), which sits in the sidecar and enters neither the package nor version control. Wiring details are in numable docs df.

i18n (metadata table B)

"lang": "zh-CN",
"title": "GitHub 示例",
"subtitle": "某个用户最近的公开动态",
"i18n": { "en-US": { "title": "GitHub Sample", "subtitle": "A user's recent public activity" } }

Bare fields are in the lang language, and translations hang beside them under i18n.<locale>; the only overridable fields are title / subtitle / category / description. Here an empty string means "translation missing" and the platform keeps falling back — the exact opposite of the content table (A), where an empty string means "deliberately blank". Full rules in numable docs i18n.

Fields not to write

What happens if you write it Field
check reports an error scheme, schemes, page/html/<route>/page.json, *.flow.json, xWidget/template/, any actionFlow/ folder
No effect at all (keeping it only misleads whoever comes next) navStyle, hideDuringAudit, logo (put the image at the package root as logo.png), poster (use banner.xbanner), defaultVipOnly

⚠️ Nothing guards the second row: check stays silent about those fields, and cloning an existing package drags them along. Read the manifest end to end after creating a package, and delete them on sight.

legacyId belongs to the same group, but it has one small use: check prints it as the package prefix at the start of an error line. New packages need not write it, and leaving an existing one in does no harm.

How to write comments

JSON has no comment syntax, so packages use a _note key throughout:

{
  "id": "hi",
  "type": "txt",
  "_note": "the title line; font size follows the widget width",
  "text": "${@i18n.title}",
  "x": "12pt", "y": "12pt", "w": "-1", "h": "-1",
  "fontSize": "16pt", "textColor": "#1A1A1A|#FFFFFF"
}

Asset conventions

What happens without a logo.png

Nothing errors, and nothing is blank: the platform draws an initial-letter placeholder icon — a solid rounded square with a single character on it.

To control what that first character looks like, change the first character of title; for any other image, ship your own logo.png.

banner.xbanner

A single file at the package root: the 16:9 banner at the top of the Tools page. Leave it out and the platform's default template is used (package name, category and the initial-letter icon); write it and the whole block is yours to draw. It is a widget's canvas: the three keys source (what to draw), depends (what data to fetch) and refresh (how often to fetch again) are written exactly as in a .xwidget; the only difference is that the size comes from scene and there is no layout.

{
  "version": 2,
  "id": "banner",
  "ratio": "16:9",
  "theme": "auto",
  "scene": { "width": 338, "height": 190, "corner": 18 },
  "params": {},
  "canvas": {
    "source": {
      "cells": [
        { "id": "bg", "type": "layer", "x": "0pt", "y": "0pt",
          "w": "{parent.w}", "h": "{parent.h}", "bgColor": "#884532|#884532" },
        { "id": "t", "type": "txt", "text": "${@i18n.t}",
          "x": "24pt", "y": "114pt", "w": "-1", "h": "-1",
          "fontSize": "20pt", "textColor": "#FFFFFF|#FFFFFF",
          "typeface": "System-Bold", "maxLines": "1" },
        { "id": "s", "type": "txt", "text": "${@i18n.s}",
          "x": "24pt", "y": "{t.b}+6pt", "w": "-1", "h": "-1",
          "fontSize": "12pt", "textColor": "#B8FFFFFF|#B8FFFFFF", "maxLines": "1" }
      ],
      "i18n": {
        "zh-CN": { "t": "我的工具", "s": "一句话说明" },
        "en-US": { "t": "My tool", "s": "One line about it" }
      }
    },
    "depends": []
  }
}

A banner that fetches data (say, a price ticker on the poster) keeps its canvas and fetch flow in xWidget/ and references them; the Tools page fetches again on the refresh schedule:

{
  "canvas": {
    "source": "@[file://xWidget/rc/banner.rcn]",
    "depends": [{ "flow": "@[file://xWidget/flow/quote.df]", "params": { "code": "${code}" } }],
    "refresh": { "interval": ["600"] }
  }
}

Five things to remember:

  1. scene is mandatory — a banner has no layout code to lean on, so it must declare its own size, width / height must be positive, and without that block the whole banner fails to render. The customary values are the 338 × 190 (16:9) above plus corner: 18.
  2. References use package-root paths. The banner lives at the package root, so @[file://…] is resolved from there: write xWidget/rc/… and xWidget/flow/…. Inside a .xwidget paths start from xWidget/, so do not copy them over as they are.
  3. It cannot see the package metadata. Things like ${meta.title} exist only in the default template and evaluate to empty in a custom banner. To show the package name, write it into the i18n of source yourself, as above.
  4. params are fixed values. ${key} in the fetch flow reads them; there is only one banner, so users cannot change them from the dashboard the way they can for a widget. If nothing is fetched, leave it an empty object, leave depends an empty array and omit refresh.
  5. Both languages are on you: the i18n rule in check (G8) does not scan .xbanner. Nobody will tell you that the banner text has no English, so switch languages and look at it before publishing.

Do not write a top-level rcn / flow: the App reads only canvas, so that shape is treated as having no banner and falls back to the default template; check stops it with G43.

Rules (break one and you rework)

Rule How it is checked Symptom when broken Fix
Every .json .rcn .df .af .xwidget .xpage .xform .xmenu .xbanner must be valid JSON check G0 The whole widget / page does not render in the app, with clean logs Fix the syntax; write comments as _note strings
The six fields id/version/title/category/domain/minEngine are required check G2 Will not install into the app Fill them in
id must be a 26-character ULID check G2 (warn) · doctor The store cannot address it and the update chain does not line up Create packages with numable init; do not cp -r another one
No files or folders that are never loaded: page.json / *.flow.json / xWidget/template/ / actionFlow/ check G1 Those files are never loaded, which looks like "I wrote it and nothing happened" RCN goes in rc/, flows in flow/, extensions become .af / .df
No test fixtures in the package (*.params.json, fixtures/) check G1b Real keys inside a fixture get signed and distributed Move them into .numable/params/
Total source-folder bytes ≤ 3MB check G13 Publishing is rejected Cut image assets
Any single file ≤ 256KB (warning) check G13 Fetching gets slower Split the flow, or turn repeated expansions into a lookup table
banner.xbanner must carry scene.width / scene.height (positive numbers) manual review (open the Tools page in the app and look at the banner) That whole block is missing while everything else is fine Add "scene": {"width": 338, "height": 190, "corner": 18}
network and the hosts actually requested match exactly (no more, no less) check G3 Too few: requests are silently blocked on a real device while the flow still reports success and the widget renders --; too many: the install panel lists a scary set of unused hosts Line it up with request.url in .df / .af and remote / fallback in router.json
Each credentials[i] needs a unique id, an allowed type, explicit hosts ⊆ network, a label in both languages, and an https help check G18 The binding panel cannot render, or the key is sent to an undeclared host Fill it in per the table above
A widget's params key names must not look like secrets (token/secret/password/api_key, or the Chinese words for key/token) check G18 The key ends up in the plain-text echo panel and in shared screenshots Move it to manifest.credentials
No scheme / schemes field check G2 / G16 A dead field with no consumer, whose value can go quietly wrong unnoticed Delete it; deep links are simply numable://<id>
A published package must have subtitle, plus complete i18n["en-US"].title/subtitle check --profile publish G19 The store and install panel show Chinese in an English environment Add the English overrides
Title width ≤ 24 units per language (full-width 2 / half-width 1) check --profile publish G21 It does not fit the two lines of a store grid tile and the name is truncated Shorten title
A published package must have logo.png: square, 512, full-bleed, no baked corners check --profile publish G11 Falls back to the initial-letter icon; or transparent notches / double arcs at the corners Re-export a full-bleed square
minEngine's major must not exceed the current engine, nor fall below what the features you use require doctor (too high) · check G45 / G51 (too low) Too high: "App version too low" only blows up at install time; too low: older apps install it and the feature silently does nothing Follow the minEngine row above
version +1 on every content change manual review Installed users never receive the update and the update chain never matches Bump it before publishing

When something goes wrong

Symptom Most likely cause What to do first
check says a fetch uses X but manifest.network does not declare it A new request was added without updating the allowlist Add the host to network
check says X is declared but no flow uses it An endpoint changed and the old host was left behind Delete that entry
Every request fails on a real device with clean logs The host is not on the allowlist Same as above; numable run enforces the same allowlist, so reproduce it locally first
run reports network_blocked:redirect_escaped:Y ("X redirected to Y; Y is not in manifest.network") The URL you request 3xx-redirects to a host outside the allowlist; the request fails on the phone too Point url at the final address the redirect lands on (preferred — the allowlist keeps a single host and check G3 stays closed); only if the redirect is unavoidable, add Y to network as well
Installing says "app version too low" minEngine's major is above the client's Check the current engine major with numable doctor and lower it
Users get no update after publishing manifest.version was not bumped Bump it and publish again
The store truncates the name / shows Chinese in an English environment The name is too long / i18n["en-US"] is missing numable check --profile publish lists both line by line
The banner at the top of the Tools page is entirely blank banner.xbanner has no scene, or some cell has no type Add scene; check G7c points at the cell that is missing type
The tool icon is a letter tile There is no logo.png at the package root, so the initial-letter placeholder kicked in Ship a 512×512 full-bleed square; check --profile publish G11 also stops it
Two packages overwrite each other in the app Copying a package with cp -r gave them the same id numable init <new folder> --from <old package>, which regenerates the identity

Related

numable docs xwidget · numable docs i18n · numable docs lint-codes