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"
}
- The key must start with
_note(_note/_noteWhy/_note2all work). Everycheckrule skips comments by that prefix; other underscore names are not guaranteed to be skipped — write a${@i18n.x}or anabs::()as an example inside one and it gets reported as a real reference. - A comment is a field on some object; it cannot stand on its own. Dropping a
{"_note": "…"}into acells/react/childrenarray breaks the whole widget (it is a cell with notype), andcheckG7c catches it.
Asset conventions
logo.png: package root, 512×512 square, full-bleed, with no rounded corners baked in. The host rounds every icon atside × 0.2237; round it yourself first and the corners end up with transparent notches or a double arc.- Total size limit 3MB (the sum of every file in the source folder); any single file over 256KB gets a warning — the larger a flow, the slower every fetch, so split it into several flows or turn repeated expanded expressions into a lookup table. Prefer drawing images in RCN, or use an in-package
.uritext asset. - Do not park non-shippable things in the package folder: fixtures, screenshots and notes all go in
.numable/(names starting with.are always skipped and never packaged), or outside the package folder entirely.
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.
- The character is the first character of
titleafter trimming, uppercased for Latin letters (a Chinese package gets its first Han character);#whentitleis empty. - The background color is picked from an 8-color palette by
manifest.id(the sum of the id's character codes modulo 8), so a given package always gets the same color and you cannot change it. - The placeholder is a perfectly normal look for a package you keep to yourself;
check --profile publishuses G11 to stop a published package that has nologo.png.
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:
sceneis mandatory — a banner has nolayoutcode to lean on, so it must declare its own size,width/heightmust be positive, and without that block the whole banner fails to render. The customary values are the338 × 190(16:9) above pluscorner: 18.- References use package-root paths. The banner lives at the package root, so
@[file://…]is resolved from there: writexWidget/rc/…andxWidget/flow/…. Inside a.xwidgetpaths start fromxWidget/, so do not copy them over as they are. - 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 thei18nofsourceyourself, as above. paramsare 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, leavedependsan empty array and omitrefresh.- 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