- name
- studio-api
- description
- How to drive the extremers studio from window.api in Chrome: the calling convention, the workflow from an empty browser to a bundle running on the C6 (describe → skill → project.create → engine.check → ui.build → preview → bundle.pack → device.*), what a person must do (the Bluetooth chooser, pairing at the Kindle, Chrome flags), and which groups are wired today. Read this first.
# studio-api — driving the studio from `window.api`
The studio is a page (http://localhost:5190 in development) that authors a
**bundle** for the C6: an expr engine (`engine/`), a Kindle page
(`kindle/src/`) and a watch app (`watch/src/`), all in one project. It checks
and runs the engine on the real host compiled to wasm, bundles the two UIs,
packs the bundle and installs it on the C6 over Web Bluetooth. Every action a
person can take on the page is a function on `window.api`, and every function
an agent calls shows on the page.
## Calling convention
- Every function is `async` and **never throws**: `const r = await api.project.list()`.
It resolves to plain JSON: `{ ok: true, ... }` or `{ ok: false, error, hint? }`.
Always check `r.ok`; read `hint` when it isn't.
- `await api.describe()` is the tool list, generated from the code: every
function with `group`, `available`, `args`, `returns`, `example`. Call it first.
- Grouped names: `api.project.create`, `api.files.read`, `api.engine.check`, …
The core group is top level: `api.describe`, `api.status`, `api.log`,
`api.skills`, `api.skill`.
- Positional args first, then one options object for the `opts.*` args:
`api.files.read('engine/engine.expr', { offset: 0, maxChars: 4000 })`. One
object with the positional names also works: `api.files.read({ path: 'engine/engine.expr' })`.
- Big results are paged: follow `nextOffset` until it is `null` (`skill`,
`files.read`, `engine.disassemble`, `sim.history`).
- An unknown name returns `{ ok:false, error: "api.x.y does not exist" }` with
the list of what does.
- `available: false` in `describe()` means the function is registered with its
final shape but not wired yet; it returns
`{ ok:false, error: "not available yet: …", hint }` naming the milestone.
## What is wired today
| Group | State |
|---|---|
| core: `describe, status, log, skills, skill` | wired |
| `project.*`: `list, create, open, delete, rename, export, import, settings` | wired (IndexedDB in this browser) |
| `files.*`: `list, read, write, delete, rename` | wired; writes save at once |
| `engine.*`: `interface, compile, check, disassemble, layout, builtins` | wired (the engine wasm) |
| `sim.*`: `runSamples, start, reset, event, gps, advance, state, history, replay, stop` | wired (the real host in the wasm) |
| `ui.build` | wired (a Web Worker in the page); the last good build per project is kept |
| `bundle.*`: `pack, inspect` | wired; the Kindle page is a placeholder until `ui.build` has run |
| `preview.*`: `open, status, text, press, close` | wired (the Kindle page and the watch app on the sim) |
| `device.*`: `status, connect, disconnect, list, install, activate, delete, get, firmware, updateFirmware` | wired (Web Bluetooth to the box; "The box" below) |
## The bundle's name: `studio.json`
The project's settings are a file at its root, `studio.json`:
`{"name": "race", "version": 1}`. `name` is the **bundle's** name, not the
project's: its package id (FNV-1a of it) is where the Kindle page loads from
(`/pkg/<id>/`), and the clients import its wire module as `'<name>-wire'`. The
Race template sets `race` whatever you call the project (its clients import
`'race-wire'`); `blank` sets the project's name. Starting from the Race
template under another bundle name takes three edits, not one:
`api.project.settings({ name: 'mark' })`, then in both `kindle/src/api.js`
and `watch/src/api.js` the import from `'race-wire'` (to `'engine-wire'`,
which resolves under any name, or `'mark-wire'`), and in `watch/src/api.js`
`packageId("race")` → `packageId("mark")` (else the watch ignores the host's
envelopes and sends its events to package `race`). `project.settings` lists
each such line still naming another bundle in `warnings`. `api.ui.build()`,
`api.sim.start()` and `api.bundle.pack()` use it when no `{ name }` is given;
`api.project.settings()` reads it, `api.project.settings({ name, version })`
writes it (as does `files.write('studio.json', …)`). `bundle.pack` and
`sim.start` refuse when the last `ui.build` was made under another name: build
again (or pack with that name). A project without the file uses its own name.
## The engine and the host
- `api.engine.check()` is the gate: it compiles, runs the PROFILE §5 smoke runs
(`{gps: {}}` on a nil state, then a wake) and `engine/samples.json`. `ok`
only when all of that passes. Every problem is a diagnostic
`{rule, severity, path, span: {line, col, offset, end}, message, help}`:
`path` is the file to fix, `help` usually is the fix. `E-*` come from the
compiler and the schemas (`E-SCHEMA`), `R-*` from a run (`R-NIL`: a nil
where a value is needed, typically `state` on the first event). The page
shows them in **#diagnostics** (click one: the editor selects its span) and
checks again by itself shortly after an engine file is saved.
- `api.sim.start()` packs the engine and starts the real host (`common::Host`):
the host evaluates `{gps: {}}` on a nil state, then the device clock is set
to `t0`. That first evaluation (`cause: 'start'`) reports `t: 0` on
purpose: the host starts the engine at uptime 0, before any device time
exists, as the C6 does at boot before its first GPS time; the next one
(`cause: 'gps'`) is `t0` setting the clock, at `t0`. Two clocks:
- **live** (the default; `t0` defaults to now): the device clock runs with
the page's, wakes fire by themselves on wall time, `advance` is refused.
This is the box's behaviour: use it with the previews (a person watching
a countdown, an agent pressing preview buttons and waiting). **A `t` in
`sim.gps` is ignored on the live clock**, as on the C6: the host stamps
each fix with the device time when it arrives, and the device clock
never jumps. Five fixes sent in one script land milliseconds apart. The
result says so in `warnings` (`t ignored on the live clock: …`), and so
does the log. For fixes at chosen times, use the virtual clock or
`api.sim.replay`, which keeps a track's own intervals on either clock.
- **virtual** (`api.sim.start({ clock: 'virtual' })`; `t0` defaults to
1790000000000): the clock moves only with `advance`, a GPS `t` ahead of
it, or a replay. Use it for scripted runs and tests, where you want exact
times: `sim.gps({ t, … })` with `t` at or ahead of the clock stamps the
fix at `t` (wakes due on the way fire first). A `t` behind the clock is
ignored, with a warning: the clock never goes back. With the previews open it looks wrong: the Kindle preview re-sends
the state every 5 s (the device's heartbeat) stamped with the virtual
time, so the page's countdown jumps back to it while wall time runs on.
`api.sim.stop()` turns a live clock virtual where it stands.
- Inputs: `api.sim.event(variant)` (your events, encoded and sent as a
client does; a refusal says `refused`), `api.sim.gps({ t, lat, lon, speed,
heading })` or NMEA text, `api.sim.advance(ms)` / `advance('wake')`,
`api.sim.replay({ path })` for a track file in the project (NMEA, or JSON
updates). Each returns `evaluations`: `{n, t, cause: start|gps|event|wake,
event, accepted, state, view, wake, published, publishedHex, error?}`, the
host's own log of what it evaluated; when the host rejected one, also
`errors: [{n, cause, code, at, help}]` (`code` `R-…`, `at`
`engine/engine.expr:line:col`).
- The host publishes a view only when its bytes change: `published: false`
is the C6's behaviour, not a bug.
- `state` is the engine's own state, read from the host (the leaves it
keeps, decoded with the state schema). The studio checks the host on every
call (`W-WIRE`: each envelope is a logged evaluation whose view changed,
and the JS codec decodes and re-encodes its bytes unchanged; `W-HOST`: the
host's answers, wakes and pending wake agree with its log).
`api.sim.state().findings` must stay empty; report it if it isn't.
- The sim's `gps` input holds the newest 20,480 fixes, as the C6's track
does (`sim.state().track`, `trackCapacity`).
- `api.sim.history({ offset, limit })` pages the last 2,000 evaluations;
the page's Run column shows them (newest first), the last evaluation's
state, view and wake, the clock (Live by default; **Manual clock** is the
virtual one, with +1 s, +10 s and the next wake), a button for each event
and the GPS fix.
## The workflow
1. `api.status()` — is a project open, is storage available, is the engine wasm loaded?
2. `api.skill('expr-engine')` before writing an engine; `kindle-ui`, `watch-ui`
and `race-example` for the clients. Read each to the end.
3. `api.project.list()`, then `api.project.create('my-race', { template: 'race' })`
(the Race example) or `{ template: 'blank' }` (a minimal engine), or
`api.project.open(name)`. `api.project.settings()`: the bundle's name and
version (`studio.json`, above).
4. `api.files.list()`, `api.files.read(path)`, `api.files.write(path, text)`:
`files.write` replaces the whole file. The page shows the change. A
`.json` file is stored pretty-printed when it parses (`formatted: true`;
only whitespace changes) and as given when it does not
(`formatted: false`, `jsonError`): read it back for the stored text.
5. `api.engine.check()` — compile, the smoke runs and `engine/samples.json`
(chained). Fix every diagnostic from its `help` before going on. Its
`compile.memory` has `locals` and `held_slots` (the locals that list
literals, built lists, and the blocks of sorts, `uniq` and `median` hold
for the whole evaluation, each its bound × item leaves: the expr-engine
skill gives each one's charge); the C6 allows 256 locals, which the compile does not
check: `sim.start` (6, below) does. `api.engine.layout()` shows the tables
the bundle carries, `stateLists` among them: each list the state keeps
between evaluations (`maxItems × itemLeaves` slots; the C6 holds 1,024
slots for up to 4 lists).
6. `api.sim.start({ clock: 'virtual' })`, then `api.sim.gps({ t, lat, lon, speed, heading })`,
`api.sim.event({ line_stbd: {} })`, `api.sim.advance(ms)`,
`api.sim.state()`, `api.sim.history()`: a scripted run of the engine on
the real host, with its refusals, publish rules and wake timer (above).
A list in the state round-trips as on the box: `api.sim.state().state`
shows the list the host kept. `sim.start` refuses a bundle the box would
(over its limits: "the engine needs more than the C6 has: locals",
state-list slots or state lists, with a hint). For the previews,
`api.sim.start()` (live) again.
7. `api.ui.build({ target: 'both' })` (under the settings' name; a failed
build keeps the last good one, and says so in `kept`),
`api.preview.open({ target: 'kindle' })`, `api.preview.text('kindle')`,
`api.preview.press('kindle', { text: 'Stbd' })`. A press returns the
`events` the page sent, each with the host's answer and its
`evaluations` as records like `sim.event`'s. On the watch a button is
found by its text only (`{ text: '' }`: the first with no text, such as a
confirm); watch nodes have no `testID` (skill watch-ui). After an edit,
`api.ui.build()` again is enough: before it answers it reopens each open
preview (Kindle, watch or both) whose target built, also a watch app that
failed to mount, and says so in `previews` (`{ watch: { reopened: true,
build: { at } } }`); `preview.status().watch.build.at` is then the new
build's. **A preview needs a running sim** (it is the host the page
connects to): opened with none, the pane says "Simulator not running",
`preview.open` answers `waitingForSim: true` with a warning,
`preview.status().kindle` has `waitingForSim: true, socketOpen: false,
socketConnecting: true`, and the Kindle page shows only its own first
screen (Loading…, then its "Connection lost" after 15 s): that is not a
broken build. `api.sim.start()` connects an open preview at once
(`socketOpen: true`, the current envelope delivered); opening one while
the sim runs connects at once too.
8. `api.bundle.pack()` (the settings' name and version; the version is an
integer 0-65535; `{ name, version }` override them), `api.bundle.inspect()`.
9. `api.device.connect()`, `api.device.install({ activate: true })`,
`api.device.list()` ("The box", below).
10. On any `{ ok:false }`: `error` + `hint`, then `api.status()` and `api.log({ last: 50 })`.
## Driving it from a browser harness
- **Read the skills and `describe` in one call each.** A harness that cuts
tool output at about 1 kB would need some 95 calls for the skills through
`api.skill(name, { offset })`. Instead, `await api.skill('expr-engine', {
show: true })` puts the whole skill, as text, in the page's
`<article id="skill-view">` (in the **Help** panel, which it opens) and
answers only `{ ok, shown: true, name, chars }`. Then read the page's text
once (the harness's page-text tool, or
`document.getElementById('skill-view').innerText`). `await
api.describe({ show: true })` does the same with the tool list (pretty
JSON). `chars` is the length: `expr-engine` is about 54,000 and the
`describe` JSON about 61,000, so raise the page-text tool's own limit
(e.g. `max_chars`) above it if it has one. The person at the page sees it
too. Use your own tab.
- **Keep the studio tab visible** (in front, in a window that is not
minimised). Chrome throttles a hidden tab's timers: at most once a second,
and after a few minutes hidden about once a minute. The live sim clock,
the previews' countdowns and the confirm windows (5 s) run on those
timers, so in a hidden tab a confirm expires before a later call reaches
it. Press a confirm in the call straight after the press that opened it,
View on GitHub