---
name: ardaform
description: Building, running and deploying ArdaForm apps (HTML/CSS/JavaScript apps for dedicated Linux devices run by ardaplayer, with window.Arda, app/manifest.json, arda.dev.json and ardaplayer -dev). Use when writing or debugging an ArdaForm app, its manifest, datasets, settings, settings page, camera, printer, keyboard or webhook code, or when helping someone deploy one.
---

# ArdaForm apps

An ArdaForm app is a folder: `app/index.html` plus its assets, and optionally
`app/manifest.json` (datasets, settings, files needed at boot, partition
choices). `ardaplayer` — the same program on a desk and on every device —
serves `app/` and injects `window.Arda` before the page's first script.
ArdaForm (portal + servers) holds the data, packs the app from a connected
GitHub branch on every push, and sends it to the devices on that branch.

## Where the truth is

- Developer manual, one page per area, with checked examples:
  https://ardaform.net/docs/manual/ (start, app-folder, manifest, settings,
  data, files, device, keyboard, printers, cameras, screens, webhooks,
  headless, settings-page, dev-mode, updates, troubleshooting — each at
  https://ardaform.net/docs/manual/<page>)
- HTTP API reference (for a program feeding or reading a project's data,
  webhooks and settings): https://ardaform.net/docs/api/ — one page per area,
  with real answers; the complete machine description is the keyless
  https://api.ardaform.net/v1/openapi.json (every operation's key scope is
  `x-scope`)
- Whole-platform summary for machines: https://ardaform.net/llms.txt
- Deployment guide as Markdown: https://ardaform.net/deployment.md
- `ardaplayer -h` is the authority for flags and `arda.dev.json` keys.

Do not invent API shapes. If a call or a manifest key is not in the manual,
say so rather than guessing; the manifest is checked strictly when it is
packed, and an unknown key fails the deploy.

## Layout

```
my-app/
  app/                 the app — packed and sent to devices; the player never writes here
    index.html
    manifest.json      optional
  gui/settings/        optional: the app's own settings page in the portal (never sent to a device)
  seed/<dataset>.json  desk only: records for a local run
  arda.dev.json        desk only: never packed; a device refuses to start with one
```

## The working loop

1. `ardaplayer -dev .` in the folder: a local run, no network, seeds loaded,
   pushes echoed locally, `Arda.config` = manifest defaults + `settings` from
   arda.dev.json. `"watch": true` reloads on save; `"console": true` opens the
   inspector.
2. `ardaplayer -dev -headless .` runs the same scripts with no window — useful
   to check logic and API calls from a terminal (`console.log` goes to the
   player's log; touching `document` throws).
3. `"device": true` in arda.dev.json makes the folder a real device; claim it
   in the portal with the code it shows.
4. Push to the connected GitHub branch to deploy.

## Order of operations and gotchas

Tell a developer these before they hit them. The full list, with examples:
https://ardaform.net/docs/manual/troubleshooting

Order of operations:

1. Write `app/manifest.json` first (datasets and settings) and run the folder
   locally with `ardaplayer -dev .` and `seed/<dataset>.json` files. Nothing
   needs a server yet.
2. Get the manifest to ArdaForm BEFORE anything else touches data: push a
   commit to a connected branch (packing creates the datasets and settings),
   or run the folder with `"device": true` in arda.dev.json (a developer-mode
   device sends its manifest as it connects). Until then the datasets do not
   exist on ArdaForm: the portal's Data tab is empty, the HTTP API cannot write
   to them, and records a device pushes are HELD on the device and retried
   (5 s, 15, 30, 60) — not lost, but not there.
3. Then feed the data (HTTP API or portal) and fill in the settings' values in
   the portal. Through the API every record needs a key (a record without one
   is refused, `NO_KEY`), and every request body, files included, is at most
   10 MB (`413 TOO_LARGE`). A setting's `default` seeds its value once, when the field is
   created; changing the default later changes nothing an operator set.
   A select may offer the device's own list instead of a written one —
   `"options": {"device": "printers"}` or `"screens"` (from v1.2.1): per
   device, no default, the dropdown is what that device reported, and the
   app writes the id with `Arda.config.set` when the setting is
   `writable.device`. Never ship the object form before every device of the
   project runs v1.2.1: an older player ignores the whole manifest.
4. Then add devices, each with a branch and, for partitioned datasets, a data
   partition. With `"partition": {"required": true}` in the manifest — and the
   project's Setup set to "Data partition: use the app's list", which is off
   by default — a device or install code without one is refused. The block's
   `options` is a written list, or `{"dataset": key, "value": field,
   "caption": field}` to take the choices from an UNPARTITIONED dataset's
   current records; devices read that form from v1.1.17, an older player
   ignores the whole manifest.
5. In the app, call `Arda.db.dataset(name)` at start-up for every dataset it
   needs, and redraw on `Arda.on('sync', name)`. From v1.1.17 a device
   subscribes every dataset its manifest declares when the app is installed
   or updated, so `needForBoot` holds the first screen until those are
   current, on the first start too; an undeclared dataset asked for by name
   is synced until the next update. Up to v1.1.16 a device syncs a dataset
   only once the app has asked for it, so a brand-new device's first screen
   starts empty. Developer mode always subscribes every manifest dataset.

Data:

- On a device every value of `Arda.db.dataset()` is an envelope
  `{key, data, deviceId, seq, createdAt, updatedAt}`; read the record's fields
  from `.data`. Seed records and developer-mode echoes are stored flat, as
  written. Unwrap both: `r.seq !== undefined && r.data !== undefined ? r.data : r`.
- `Arda.db.dataset()` can resolve `null` before anything has arrived: use
  `rows || {}`.
- A device sees records it pushed only after the round trip (outbox →
  ArdaForm → the dataset's next sync), and only if the app reads that dataset
  and the record is in the device's partition. Developer mode echoes pushes
  into the local store at once, which hides this.
- Always pass a key: `Arda.db.push(dataset, data, key)`. Developer mode reads
  back only keyed records. One record ≤ 128 KB (refused at push); pictures go
  in files. Only `"access": "rw"` datasets accept pushes.
- `Arda.db.status()` → `{ready, connected, since, datasets: {name: {synced,
  syncedAt, boot, records, pending, lastAckAt}}, files}` tells whether the boot
  datasets were walked since the player started, per dataset whether it was
  walked at least once and how many records the device holds, and the file
  sweep. From v1.2.0 `pending` is how many of THIS device's pushes to the
  dataset are still queued (zero = every write is up, restarts included — the
  outbox is on disk), `lastAckAt` when ArdaForm last answered one, and
  `connected` is the control connection right now (`since`: when it went up or
  down; `'device'` fires on a change). `Arda.on('ack', a)` names the seqs
  stored (`a.acked`) and refused (`a.refused`, final — the only place a refusal
  is heard); a HELD record (no such dataset yet) is in neither list and stays
  pending. Money on an existing record waits for `pending` 0 or its seq in
  `acked` — never a timer. `Arda.db.sync()` → `{kicked}` walks everything now,
  once per 5 s. Up to v1.1.17 neither the outbox nor the connection is
  reported (feature-check `typeof Arda.db.sync === 'function'`). In a local
  developer run `ready`, `synced` and `connected` are always false.
- `needForBoot`: when any dataset has it, the app starts once those are
  current; when none has, every synced dataset gates the first screen.
  From v1.1.17 this holds on a device's first start too (up to v1.1.16 a
  brand-new device's first start held nothing back).
- Changing a device's partition or branch in the portal is a resync: its data
  and files are dropped and the app starts again.

Settings and the manifest:

- `Arda.config` is a frozen object replaced wholesale on every change: read
  `Arda.config.x` each time it is used, never keep the object; listen with
  `Arda.on('config', fn)`. An unset setting is ABSENT (not null): read with
  `??`, never `||` (a switched-off bool would read as the default). Keys
  starting `arda_` are refused.
- Who may change a value is the setting's `writable` block, default
  `{"portal": true, "api": true, "device": false}`; a member left out keeps its
  default. `portal: false` locks it in the portal, `api: false` makes the HTTP
  API answer `403 NOT_WRITABLE`. With `device: true` (from v1.1.17) the app
  writes THIS device's own value: `await Arda.config.set(key, value)` →
  `{ok: true}` or `{ok: false, error, message}` (error `NOT_WRITABLE`,
  `UNKNOWN_KEY`, `INVALID`, `FAILED`; later `STALE` via
  `Arda.on('configRefused')` when the portal changed it meanwhile); `null`
  clears it. Never the project's value. Refused on a rows setting with a
  `secret` field. Feature-check `typeof Arda.config.set === 'function'`, and
  write only after the first configuration has arrived.
- A `list` setting reaches the app as an array of strings (ArdaForm sent JSON
  text before 19 October 2026). `rows` is always an array of objects.
- Never put secrets in the manifest (it is in the repository and in every
  device's app). A password goes in a `rows` field with `"secret": true` —
  `secret` exists only on rows fields; `min`/`max` exist only on rows. The
  device still receives the value: never log it or show it.
- The plane is strict (an unknown key or bad value fails the pack, every fault
  listed, no device updated); the player is lenient (logs and ignores). A
  dataset dropped from the manifest becomes an orphan in the portal, kept until
  deleted by hand. Nothing is deleted automatically.

The folder and developer mode:

- `arda.dev.json` (beside `app/`, never inside) is for developer mode only:
  outside `-dev` the player refuses to start if it finds one, and a container
  holding one does not open. ArdaForm never packs it. An unknown key is
  refused; the command line wins over the file. Use pretend passwords and
  throwaway webhook keys in it. Its `webhooks` list is used even with
  `"device": true`.
- The player adds nothing to `app/`; `app/` is packed exactly as it is (no
  build runs; a symbolic link fails the pack; dotfiles, `.env*`, `.git`,
  `.github` are left out; 4096 files / 64 MiB). Keep `state/` (a desk device's
  private key), `data/`, `storage/` and `*.adb` out of git.

Hardware and the page:

- A camera's login goes in `Arda.camera.set(name, {url, user, password})`,
  never in the link (`rtsp://user:pass@…` still works but is not the way). A
  password without a user is refused. Never call `Arda.camera.onvif()` in a
  loop with a wrong password: it is tried once, and cameras lock accounts.
- `Arda.keyboard()` is opened by the app (nothing watches focused inputs) and
  resolves `null` on cancel. It renders in a shadow root: the page's CSS cannot
  style it. Use `theme` (a closed list of names: accent, bg, key, keyAlt,
  keySpecial, keyText, fieldBg, fieldText, fieldTextOn, backdrop, radius, font,
  keyHeight, keyGap, unit — others are refused with a console warning) and
  `css`; set custom properties on the `keyboard` element, not `:host`. It is
  sized from the screen, not the page's `html { font-size }` (v1.1.12+); scale
  it with `theme.unit`. Only the `pin` layout listens to a physical keyboard.
- `Arda.on` has no `off()`: register listeners once. `'device'` fires on
  phase, lock and registration changes, and from v1.2.0 when the control
  connection goes up or down (read `Arda.db.status().connected`).
- Barcode scanners and card readers also type into the focused field.
- Printers: check `usable`; text is a single-byte code page, not UTF-8; strip
  control bytes from data before building a print stream; `confirmed: false`
  means the printer took the copy, not that it printed.
- A camera on a second monitor (`Arda.camera.show`, feature `cameraScreen`)
  needs a PC-type device installed from the v1.1.12 images or later. A
  Raspberry Pi has one screen, prefers H.264 cameras, and decodes six streams
  in hardware at the stock `gpu_mem`.
- Headless (`ardaplayer -headless myapp/`): no DOM (`document` throws), classic
  scripts only (no ES modules), `fetch`/`WebSocket` to any server. From
  v1.1.17 a device with no screen (or `-headless` with no folder) runs the
  deployed app through the headless runtime: index.html's classic scripts,
  inline or a local `src`, in document order; restarted on each update, the
  update negotiation as on a screen, `location.reload()` restarts it, stopped
  while the device is locked. Not yet proved on a device. Up to v1.1.16 a
  deployed app is a page and is not run there (the device stays connected
  for recovery).
- Webhooks are called by ArdaForm's servers, not the device: an address on the
  shop's own network is unreachable. A 4xx/5xx from the server is a result
  (`ok: false`); the promise rejects only when nobody was asked.
- The settings page (`gui/settings/`) has no network, no storage, no
  alert/confirm, one document only; it proposes values and nothing is saved
  until the operator presses Update.

Where to look when nothing shows:

- Switch the device's log capture ON in the portal (device › Alerts & log)
  first: the device's warnings and the app's `console` output reach the portal
  only while it is on. It expires by itself; the device keeps its last few
  hundred lines, so switching it on shows what already happened.
- Device view (portal) evaluates an expression in the running page:
  `Arda.db.status()`, `Arda.config`, `Arda.db.dataset('name')`.
- "Registered — waiting for this device's application": the branch has no
  connection or the folder no `index.html` (Connectors, Code). A deploy that
  did not happen: a manifest fault failed the pack (Code/Connectors list it).
